From 2ed9d37da7b27d25173535550fb92702225ac14e Mon Sep 17 00:00:00 2001 From: Christian Kolset Date: Mon, 13 Apr 2026 15:22:56 -0600 Subject: Removed QtPy5 snippet and fixed directories --- api_layers/__init__.py | 5 + api_layers/__pycache__/__init__.cpython-314.pyc | Bin 0 -> 324 bytes .../__pycache__/arduino_layer.cpython-314.pyc | Bin 0 -> 15070 bytes .../__pycache__/nidaqmx_layer.cpython-314.pyc | Bin 0 -> 10251 bytes api_layers/arduino_layer.py | 295 ++++++++++++++++ api_layers/nidaqmx_layer.py | 181 ++++++++++ core/__init__.py | 3 + core/__pycache__/__init__.cpython-314.pyc | Bin 0 -> 277 bytes core/__pycache__/acquisition.cpython-314.pyc | Bin 0 -> 14572 bytes core/acquisition.py | 225 ++++++++++++ daq_system/core/__init__.py | 1 - .../core/__pycache__/__init__.cpython-312.pyc | Bin 161 -> 0 bytes .../core/__pycache__/acquisition.cpython-312.pyc | Bin 11381 -> 0 bytes daq_system/core/acquisition.py | 214 ------------ daq_system/daq_system/README.md | 183 ---------- daq_system/daq_system/api_layers/__init__.py | 5 - daq_system/daq_system/api_layers/arduino_layer.py | 295 ---------------- daq_system/daq_system/api_layers/nidaqmx_layer.py | 181 ---------- daq_system/daq_system/core/__init__.py | 3 - daq_system/daq_system/core/acquisition.py | 225 ------------ daq_system/daq_system/devices/__init__.py | 5 - daq_system/daq_system/devices/analog_input.py | 276 --------------- daq_system/daq_system/devices/base_device.py | 104 ------ daq_system/daq_system/devices/device_registry.py | 73 ---- daq_system/daq_system/devices/digital_io.py | 261 -------------- daq_system/daq_system/devices/serial_device.py | 187 ---------- daq_system/daq_system/main.py | 40 --- daq_system/daq_system/requirements.txt | 8 - daq_system/daq_system/ui/__init__.py | 1 - daq_system/daq_system/ui/add_device_dialog.py | 100 ------ daq_system/daq_system/ui/alarm_panel.py | 85 ----- daq_system/daq_system/ui/config_dialog.py | 111 ------ daq_system/daq_system/ui/device_panel.py | 148 -------- daq_system/daq_system/ui/main_window.py | 214 ------------ daq_system/daq_system/ui/readout_panel.py | 122 ------- daq_system/daq_system/ui/strip_chart.py | 186 ---------- daq_system/daq_system/ui/style.qss | 378 --------------------- daq_system/devices/__init__.py | 1 - .../devices/__pycache__/__init__.cpython-312.pyc | Bin 164 -> 0 bytes .../__pycache__/analog_input.cpython-312.pyc | Bin 9034 -> 0 bytes .../__pycache__/base_device.cpython-312.pyc | Bin 6014 -> 0 bytes .../__pycache__/device_registry.cpython-312.pyc | Bin 5219 -> 0 bytes .../devices/__pycache__/digital_io.cpython-312.pyc | Bin 6027 -> 0 bytes .../__pycache__/serial_device.cpython-312.pyc | Bin 7405 -> 0 bytes .../__pycache__/temperature.cpython-312.pyc | Bin 6707 -> 0 bytes daq_system/devices/analog_input.py | 184 ---------- daq_system/devices/base_device.py | 127 ------- daq_system/devices/device_registry.py | 87 ----- daq_system/devices/digital_io.py | 95 ------ daq_system/devices/serial_device.py | 136 -------- daq_system/devices/temperature.py | 115 ------- daq_system/main.py | 28 -- .../ui/__pycache__/alarm_panel.cpython-312.pyc | Bin 5462 -> 0 bytes .../ui/__pycache__/config_dialog.cpython-312.pyc | Bin 9345 -> 0 bytes .../ui/__pycache__/device_panel.cpython-312.pyc | Bin 7943 -> 0 bytes .../ui/__pycache__/main_window.cpython-312.pyc | Bin 13127 -> 0 bytes .../ui/__pycache__/readout_panel.cpython-312.pyc | Bin 8378 -> 0 bytes .../ui/__pycache__/strip_chart.cpython-312.pyc | Bin 8328 -> 0 bytes daq_system/ui/add_device_dialog.py | 7 - daq_system/ui/alarm_panel.py | 85 ----- daq_system/ui/config_dialog.py | 174 ---------- daq_system/ui/device_panel.py | 127 ------- daq_system/ui/main_window.py | 228 ------------- daq_system/ui/readout_panel.py | 128 ------- daq_system/ui/strip_chart.py | 140 -------- devices/__init__.py | 5 + devices/__pycache__/__init__.cpython-314.pyc | Bin 0 -> 398 bytes devices/__pycache__/analog_input.cpython-314.pyc | Bin 0 -> 16238 bytes devices/__pycache__/base_device.cpython-314.pyc | Bin 0 -> 7731 bytes .../__pycache__/device_registry.cpython-314.pyc | Bin 0 -> 5927 bytes devices/__pycache__/digital_io.cpython-314.pyc | Bin 0 -> 19275 bytes devices/__pycache__/serial_device.cpython-314.pyc | Bin 0 -> 11824 bytes devices/analog_input.py | 276 +++++++++++++++ devices/base_device.py | 104 ++++++ devices/device_registry.py | 73 ++++ devices/digital_io.py | 261 ++++++++++++++ devices/serial_device.py | 187 ++++++++++ main.py | 40 +++ requirements.txt | 8 + ui/__init__.py | 1 + ui/__pycache__/__init__.cpython-314.pyc | Bin 0 -> 153 bytes ui/__pycache__/add_device_dialog.cpython-314.pyc | Bin 0 -> 5942 bytes ui/__pycache__/alarm_panel.cpython-314.pyc | Bin 0 -> 6113 bytes ui/__pycache__/config_dialog.cpython-314.pyc | Bin 0 -> 7331 bytes ui/__pycache__/device_panel.cpython-314.pyc | Bin 0 -> 10002 bytes ui/__pycache__/main_window.cpython-314.pyc | Bin 0 -> 15798 bytes ui/__pycache__/readout_panel.cpython-314.pyc | Bin 0 -> 9028 bytes ui/__pycache__/strip_chart.cpython-314.pyc | Bin 0 -> 11052 bytes ui/add_device_dialog.py | 100 ++++++ ui/alarm_panel.py | 85 +++++ ui/config_dialog.py | 111 ++++++ ui/device_panel.py | 148 ++++++++ ui/main_window.py | 214 ++++++++++++ ui/readout_panel.py | 122 +++++++ ui/strip_chart.py | 186 ++++++++++ ui/style.qss | 378 +++++++++++++++++++++ 96 files changed, 3008 insertions(+), 5068 deletions(-) create mode 100644 api_layers/__init__.py create mode 100644 api_layers/__pycache__/__init__.cpython-314.pyc create mode 100644 api_layers/__pycache__/arduino_layer.cpython-314.pyc create mode 100644 api_layers/__pycache__/nidaqmx_layer.cpython-314.pyc create mode 100644 api_layers/arduino_layer.py create mode 100644 api_layers/nidaqmx_layer.py create mode 100644 core/__init__.py create mode 100644 core/__pycache__/__init__.cpython-314.pyc create mode 100644 core/__pycache__/acquisition.cpython-314.pyc create mode 100644 core/acquisition.py delete mode 100644 daq_system/core/__init__.py delete mode 100644 daq_system/core/__pycache__/__init__.cpython-312.pyc delete mode 100644 daq_system/core/__pycache__/acquisition.cpython-312.pyc delete mode 100644 daq_system/core/acquisition.py delete mode 100644 daq_system/daq_system/README.md delete mode 100644 daq_system/daq_system/api_layers/__init__.py delete mode 100644 daq_system/daq_system/api_layers/arduino_layer.py delete mode 100644 daq_system/daq_system/api_layers/nidaqmx_layer.py delete mode 100644 daq_system/daq_system/core/__init__.py delete mode 100644 daq_system/daq_system/core/acquisition.py delete mode 100644 daq_system/daq_system/devices/__init__.py delete mode 100644 daq_system/daq_system/devices/analog_input.py delete mode 100644 daq_system/daq_system/devices/base_device.py delete mode 100644 daq_system/daq_system/devices/device_registry.py delete mode 100644 daq_system/daq_system/devices/digital_io.py delete mode 100644 daq_system/daq_system/devices/serial_device.py delete mode 100644 daq_system/daq_system/main.py delete mode 100644 daq_system/daq_system/requirements.txt delete mode 100644 daq_system/daq_system/ui/__init__.py delete mode 100644 daq_system/daq_system/ui/add_device_dialog.py delete mode 100644 daq_system/daq_system/ui/alarm_panel.py delete mode 100644 daq_system/daq_system/ui/config_dialog.py delete mode 100644 daq_system/daq_system/ui/device_panel.py delete mode 100644 daq_system/daq_system/ui/main_window.py delete mode 100644 daq_system/daq_system/ui/readout_panel.py delete mode 100644 daq_system/daq_system/ui/strip_chart.py delete mode 100644 daq_system/daq_system/ui/style.qss delete mode 100644 daq_system/devices/__init__.py delete mode 100644 daq_system/devices/__pycache__/__init__.cpython-312.pyc delete mode 100644 daq_system/devices/__pycache__/analog_input.cpython-312.pyc delete mode 100644 daq_system/devices/__pycache__/base_device.cpython-312.pyc delete mode 100644 daq_system/devices/__pycache__/device_registry.cpython-312.pyc delete mode 100644 daq_system/devices/__pycache__/digital_io.cpython-312.pyc delete mode 100644 daq_system/devices/__pycache__/serial_device.cpython-312.pyc delete mode 100644 daq_system/devices/__pycache__/temperature.cpython-312.pyc delete mode 100644 daq_system/devices/analog_input.py delete mode 100644 daq_system/devices/base_device.py delete mode 100644 daq_system/devices/device_registry.py delete mode 100644 daq_system/devices/digital_io.py delete mode 100644 daq_system/devices/serial_device.py delete mode 100644 daq_system/devices/temperature.py delete mode 100644 daq_system/main.py delete mode 100644 daq_system/ui/__pycache__/alarm_panel.cpython-312.pyc delete mode 100644 daq_system/ui/__pycache__/config_dialog.cpython-312.pyc delete mode 100644 daq_system/ui/__pycache__/device_panel.cpython-312.pyc delete mode 100644 daq_system/ui/__pycache__/main_window.cpython-312.pyc delete mode 100644 daq_system/ui/__pycache__/readout_panel.cpython-312.pyc delete mode 100644 daq_system/ui/__pycache__/strip_chart.cpython-312.pyc delete mode 100644 daq_system/ui/add_device_dialog.py delete mode 100644 daq_system/ui/alarm_panel.py delete mode 100644 daq_system/ui/config_dialog.py delete mode 100644 daq_system/ui/device_panel.py delete mode 100644 daq_system/ui/main_window.py delete mode 100644 daq_system/ui/readout_panel.py delete mode 100644 daq_system/ui/strip_chart.py create mode 100644 devices/__init__.py create mode 100644 devices/__pycache__/__init__.cpython-314.pyc create mode 100644 devices/__pycache__/analog_input.cpython-314.pyc create mode 100644 devices/__pycache__/base_device.cpython-314.pyc create mode 100644 devices/__pycache__/device_registry.cpython-314.pyc create mode 100644 devices/__pycache__/digital_io.cpython-314.pyc create mode 100644 devices/__pycache__/serial_device.cpython-314.pyc create mode 100644 devices/analog_input.py create mode 100644 devices/base_device.py create mode 100644 devices/device_registry.py create mode 100644 devices/digital_io.py create mode 100644 devices/serial_device.py create mode 100755 main.py create mode 100644 requirements.txt create mode 100644 ui/__init__.py create mode 100644 ui/__pycache__/__init__.cpython-314.pyc create mode 100644 ui/__pycache__/add_device_dialog.cpython-314.pyc create mode 100644 ui/__pycache__/alarm_panel.cpython-314.pyc create mode 100644 ui/__pycache__/config_dialog.cpython-314.pyc create mode 100644 ui/__pycache__/device_panel.cpython-314.pyc create mode 100644 ui/__pycache__/main_window.cpython-314.pyc create mode 100644 ui/__pycache__/readout_panel.cpython-314.pyc create mode 100644 ui/__pycache__/strip_chart.cpython-314.pyc create mode 100644 ui/add_device_dialog.py create mode 100644 ui/alarm_panel.py create mode 100644 ui/config_dialog.py create mode 100644 ui/device_panel.py create mode 100644 ui/main_window.py create mode 100644 ui/readout_panel.py create mode 100644 ui/strip_chart.py create mode 100644 ui/style.qss diff --git a/api_layers/__init__.py b/api_layers/__init__.py new file mode 100644 index 0000000..242d6a9 --- /dev/null +++ b/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/api_layers/__pycache__/__init__.cpython-314.pyc b/api_layers/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..88ad51b Binary files /dev/null and b/api_layers/__pycache__/__init__.cpython-314.pyc differ diff --git a/api_layers/__pycache__/arduino_layer.cpython-314.pyc b/api_layers/__pycache__/arduino_layer.cpython-314.pyc new file mode 100644 index 0000000..186b224 Binary files /dev/null and b/api_layers/__pycache__/arduino_layer.cpython-314.pyc differ diff --git a/api_layers/__pycache__/nidaqmx_layer.cpython-314.pyc b/api_layers/__pycache__/nidaqmx_layer.cpython-314.pyc new file mode 100644 index 0000000..92f0ff3 Binary files /dev/null and b/api_layers/__pycache__/nidaqmx_layer.cpython-314.pyc differ diff --git a/api_layers/arduino_layer.py b/api_layers/arduino_layer.py new file mode 100644 index 0000000..dea2783 --- /dev/null +++ b/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"" + + +# ════════════════════════════════════════════════════════════════════════════ +# 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/api_layers/nidaqmx_layer.py b/api_layers/nidaqmx_layer.py new file mode 100644 index 0000000..ba40efb --- /dev/null +++ b/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"" diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..06fabd5 --- /dev/null +++ b/core/__init__.py @@ -0,0 +1,3 @@ +# core/__init__.py +from core.acquisition import AcquisitionEngine, ChannelBuffer +__all__ = ["AcquisitionEngine", "ChannelBuffer"] diff --git a/core/__pycache__/__init__.cpython-314.pyc b/core/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..2a7cb7e Binary files /dev/null and b/core/__pycache__/__init__.cpython-314.pyc differ diff --git a/core/__pycache__/acquisition.cpython-314.pyc b/core/__pycache__/acquisition.cpython-314.pyc new file mode 100644 index 0000000..d84b66b Binary files /dev/null and b/core/__pycache__/acquisition.cpython-314.pyc differ diff --git a/core/acquisition.py b/core/acquisition.py new file mode 100644 index 0000000..640e6d5 --- /dev/null +++ b/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/core/__init__.py b/daq_system/core/__init__.py deleted file mode 100644 index 91db526..0000000 --- a/daq_system/core/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# core/__init__.py diff --git a/daq_system/core/__pycache__/__init__.cpython-312.pyc b/daq_system/core/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 0ffb7a0..0000000 Binary files a/daq_system/core/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/daq_system/core/__pycache__/acquisition.cpython-312.pyc b/daq_system/core/__pycache__/acquisition.cpython-312.pyc deleted file mode 100644 index 8e4bde7..0000000 Binary files a/daq_system/core/__pycache__/acquisition.cpython-312.pyc and /dev/null differ diff --git a/daq_system/core/acquisition.py b/daq_system/core/acquisition.py deleted file mode 100644 index 7e43529..0000000 --- a/daq_system/core/acquisition.py +++ /dev/null @@ -1,214 +0,0 @@ -""" -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 deleted file mode 100644 index 6ef85cc..0000000 --- a/daq_system/daq_system/README.md +++ /dev/null @@ -1,183 +0,0 @@ -# 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 deleted file mode 100644 index 242d6a9..0000000 --- a/daq_system/daq_system/api_layers/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -# 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 deleted file mode 100644 index dea2783..0000000 --- a/daq_system/daq_system/api_layers/arduino_layer.py +++ /dev/null @@ -1,295 +0,0 @@ -""" -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"" - - -# ════════════════════════════════════════════════════════════════════════════ -# 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 deleted file mode 100644 index ba40efb..0000000 --- a/daq_system/daq_system/api_layers/nidaqmx_layer.py +++ /dev/null @@ -1,181 +0,0 @@ -""" -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"" diff --git a/daq_system/daq_system/core/__init__.py b/daq_system/daq_system/core/__init__.py deleted file mode 100644 index 06fabd5..0000000 --- a/daq_system/daq_system/core/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -# 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 deleted file mode 100644 index 640e6d5..0000000 --- a/daq_system/daq_system/core/acquisition.py +++ /dev/null @@ -1,225 +0,0 @@ -""" -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 deleted file mode 100644 index f79b333..0000000 --- a/daq_system/daq_system/devices/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -# 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 deleted file mode 100644 index 5bb5d28..0000000 --- a/daq_system/daq_system/devices/analog_input.py +++ /dev/null @@ -1,276 +0,0 @@ -""" -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 deleted file mode 100644 index 922a743..0000000 --- a/daq_system/daq_system/devices/base_device.py +++ /dev/null @@ -1,104 +0,0 @@ -""" -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 deleted file mode 100644 index 8ed6886..0000000 --- a/daq_system/daq_system/devices/device_registry.py +++ /dev/null @@ -1,73 +0,0 @@ -""" -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 deleted file mode 100644 index 23a345f..0000000 --- a/daq_system/daq_system/devices/digital_io.py +++ /dev/null @@ -1,261 +0,0 @@ -""" -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 deleted file mode 100644 index 6bb7bf1..0000000 --- a/daq_system/daq_system/devices/serial_device.py +++ /dev/null @@ -1,187 +0,0 @@ -""" -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 deleted file mode 100644 index 2f2d515..0000000 --- a/daq_system/daq_system/main.py +++ /dev/null @@ -1,40 +0,0 @@ -""" -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 deleted file mode 100644 index d5114b5..0000000 --- a/daq_system/daq_system/requirements.txt +++ /dev/null @@ -1,8 +0,0 @@ -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 deleted file mode 100644 index dfde25c..0000000 --- a/daq_system/daq_system/ui/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# 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 deleted file mode 100644 index 30d0016..0000000 --- a/daq_system/daq_system/ui/add_device_dialog.py +++ /dev/null @@ -1,100 +0,0 @@ -""" -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 deleted file mode 100644 index 1270aff..0000000 --- a/daq_system/daq_system/ui/alarm_panel.py +++ /dev/null @@ -1,85 +0,0 @@ -""" -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 deleted file mode 100644 index 7d87772..0000000 --- a/daq_system/daq_system/ui/config_dialog.py +++ /dev/null @@ -1,111 +0,0 @@ -""" -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 deleted file mode 100644 index 932f536..0000000 --- a/daq_system/daq_system/ui/device_panel.py +++ /dev/null @@ -1,148 +0,0 @@ -""" -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 deleted file mode 100644 index d329571..0000000 --- a/daq_system/daq_system/ui/main_window.py +++ /dev/null @@ -1,214 +0,0 @@ -""" -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 deleted file mode 100644 index ef04e18..0000000 --- a/daq_system/daq_system/ui/readout_panel.py +++ /dev/null @@ -1,122 +0,0 @@ -""" -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 deleted file mode 100644 index afc7cf7..0000000 --- a/daq_system/daq_system/ui/strip_chart.py +++ /dev/null @@ -1,186 +0,0 @@ -""" -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 deleted file mode 100644 index 60415fb..0000000 --- a/daq_system/daq_system/ui/style.qss +++ /dev/null @@ -1,378 +0,0 @@ -/* 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 deleted file mode 100644 index 9c98181..0000000 --- a/daq_system/devices/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# devices/__init__.py diff --git a/daq_system/devices/__pycache__/__init__.cpython-312.pyc b/daq_system/devices/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index af251ba..0000000 Binary files a/daq_system/devices/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/daq_system/devices/__pycache__/analog_input.cpython-312.pyc b/daq_system/devices/__pycache__/analog_input.cpython-312.pyc deleted file mode 100644 index efe5752..0000000 Binary files a/daq_system/devices/__pycache__/analog_input.cpython-312.pyc and /dev/null differ diff --git a/daq_system/devices/__pycache__/base_device.cpython-312.pyc b/daq_system/devices/__pycache__/base_device.cpython-312.pyc deleted file mode 100644 index 4f08c29..0000000 Binary files a/daq_system/devices/__pycache__/base_device.cpython-312.pyc and /dev/null differ diff --git a/daq_system/devices/__pycache__/device_registry.cpython-312.pyc b/daq_system/devices/__pycache__/device_registry.cpython-312.pyc deleted file mode 100644 index 8d5ca55..0000000 Binary files a/daq_system/devices/__pycache__/device_registry.cpython-312.pyc and /dev/null differ diff --git a/daq_system/devices/__pycache__/digital_io.cpython-312.pyc b/daq_system/devices/__pycache__/digital_io.cpython-312.pyc deleted file mode 100644 index 9ca7bc7..0000000 Binary files a/daq_system/devices/__pycache__/digital_io.cpython-312.pyc and /dev/null differ diff --git a/daq_system/devices/__pycache__/serial_device.cpython-312.pyc b/daq_system/devices/__pycache__/serial_device.cpython-312.pyc deleted file mode 100644 index f4a2190..0000000 Binary files a/daq_system/devices/__pycache__/serial_device.cpython-312.pyc and /dev/null differ diff --git a/daq_system/devices/__pycache__/temperature.cpython-312.pyc b/daq_system/devices/__pycache__/temperature.cpython-312.pyc deleted file mode 100644 index ab2a2b9..0000000 Binary files a/daq_system/devices/__pycache__/temperature.cpython-312.pyc and /dev/null differ diff --git a/daq_system/devices/analog_input.py b/daq_system/devices/analog_input.py deleted file mode 100644 index 8aaf9db..0000000 --- a/daq_system/devices/analog_input.py +++ /dev/null @@ -1,184 +0,0 @@ -""" -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 deleted file mode 100644 index 4898a7a..0000000 --- a/daq_system/devices/base_device.py +++ /dev/null @@ -1,127 +0,0 @@ -""" -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 deleted file mode 100644 index e407bc3..0000000 --- a/daq_system/devices/device_registry.py +++ /dev/null @@ -1,87 +0,0 @@ -""" -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 deleted file mode 100644 index b78b5cf..0000000 --- a/daq_system/devices/digital_io.py +++ /dev/null @@ -1,95 +0,0 @@ -""" -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 deleted file mode 100644 index 842a401..0000000 --- a/daq_system/devices/serial_device.py +++ /dev/null @@ -1,136 +0,0 @@ -""" -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 deleted file mode 100644 index 5427cf6..0000000 --- a/daq_system/devices/temperature.py +++ /dev/null @@ -1,115 +0,0 @@ -""" -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 deleted file mode 100644 index d4979b6..0000000 --- a/daq_system/main.py +++ /dev/null @@ -1,28 +0,0 @@ -""" -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 deleted file mode 100644 index 7f2776c..0000000 Binary files a/daq_system/ui/__pycache__/alarm_panel.cpython-312.pyc and /dev/null differ diff --git a/daq_system/ui/__pycache__/config_dialog.cpython-312.pyc b/daq_system/ui/__pycache__/config_dialog.cpython-312.pyc deleted file mode 100644 index d62887f..0000000 Binary files a/daq_system/ui/__pycache__/config_dialog.cpython-312.pyc and /dev/null differ diff --git a/daq_system/ui/__pycache__/device_panel.cpython-312.pyc b/daq_system/ui/__pycache__/device_panel.cpython-312.pyc deleted file mode 100644 index 0616690..0000000 Binary files a/daq_system/ui/__pycache__/device_panel.cpython-312.pyc and /dev/null differ diff --git a/daq_system/ui/__pycache__/main_window.cpython-312.pyc b/daq_system/ui/__pycache__/main_window.cpython-312.pyc deleted file mode 100644 index d6af385..0000000 Binary files a/daq_system/ui/__pycache__/main_window.cpython-312.pyc and /dev/null differ diff --git a/daq_system/ui/__pycache__/readout_panel.cpython-312.pyc b/daq_system/ui/__pycache__/readout_panel.cpython-312.pyc deleted file mode 100644 index 47d94da..0000000 Binary files a/daq_system/ui/__pycache__/readout_panel.cpython-312.pyc and /dev/null differ diff --git a/daq_system/ui/__pycache__/strip_chart.cpython-312.pyc b/daq_system/ui/__pycache__/strip_chart.cpython-312.pyc deleted file mode 100644 index 95dc075..0000000 Binary files a/daq_system/ui/__pycache__/strip_chart.cpython-312.pyc and /dev/null differ diff --git a/daq_system/ui/add_device_dialog.py b/daq_system/ui/add_device_dialog.py deleted file mode 100644 index 8851c99..0000000 --- a/daq_system/ui/add_device_dialog.py +++ /dev/null @@ -1,7 +0,0 @@ -""" -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 deleted file mode 100644 index 590d785..0000000 --- a/daq_system/ui/alarm_panel.py +++ /dev/null @@ -1,85 +0,0 @@ -""" -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 deleted file mode 100644 index a7b0cdf..0000000 --- a/daq_system/ui/config_dialog.py +++ /dev/null @@ -1,174 +0,0 @@ -""" -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 deleted file mode 100644 index 9b6c7e4..0000000 --- a/daq_system/ui/device_panel.py +++ /dev/null @@ -1,127 +0,0 @@ -""" -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 deleted file mode 100644 index 8b602d1..0000000 --- a/daq_system/ui/main_window.py +++ /dev/null @@ -1,228 +0,0 @@ -""" -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 deleted file mode 100644 index 6086c30..0000000 --- a/daq_system/ui/readout_panel.py +++ /dev/null @@ -1,128 +0,0 @@ -""" -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 deleted file mode 100644 index 46d73fa..0000000 --- a/daq_system/ui/strip_chart.py +++ /dev/null @@ -1,140 +0,0 @@ -""" -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) diff --git a/devices/__init__.py b/devices/__init__.py new file mode 100644 index 0000000..f79b333 --- /dev/null +++ b/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/devices/__pycache__/__init__.cpython-314.pyc b/devices/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..9ab29a6 Binary files /dev/null and b/devices/__pycache__/__init__.cpython-314.pyc differ diff --git a/devices/__pycache__/analog_input.cpython-314.pyc b/devices/__pycache__/analog_input.cpython-314.pyc new file mode 100644 index 0000000..4c08631 Binary files /dev/null and b/devices/__pycache__/analog_input.cpython-314.pyc differ diff --git a/devices/__pycache__/base_device.cpython-314.pyc b/devices/__pycache__/base_device.cpython-314.pyc new file mode 100644 index 0000000..ade2b20 Binary files /dev/null and b/devices/__pycache__/base_device.cpython-314.pyc differ diff --git a/devices/__pycache__/device_registry.cpython-314.pyc b/devices/__pycache__/device_registry.cpython-314.pyc new file mode 100644 index 0000000..608c8ee Binary files /dev/null and b/devices/__pycache__/device_registry.cpython-314.pyc differ diff --git a/devices/__pycache__/digital_io.cpython-314.pyc b/devices/__pycache__/digital_io.cpython-314.pyc new file mode 100644 index 0000000..b2bb27f Binary files /dev/null and b/devices/__pycache__/digital_io.cpython-314.pyc differ diff --git a/devices/__pycache__/serial_device.cpython-314.pyc b/devices/__pycache__/serial_device.cpython-314.pyc new file mode 100644 index 0000000..0674606 Binary files /dev/null and b/devices/__pycache__/serial_device.cpython-314.pyc differ diff --git a/devices/analog_input.py b/devices/analog_input.py new file mode 100644 index 0000000..5bb5d28 --- /dev/null +++ b/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/devices/base_device.py b/devices/base_device.py new file mode 100644 index 0000000..922a743 --- /dev/null +++ b/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/devices/device_registry.py b/devices/device_registry.py new file mode 100644 index 0000000..8ed6886 --- /dev/null +++ b/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/devices/digital_io.py b/devices/digital_io.py new file mode 100644 index 0000000..23a345f --- /dev/null +++ b/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/devices/serial_device.py b/devices/serial_device.py new file mode 100644 index 0000000..6bb7bf1 --- /dev/null +++ b/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/main.py b/main.py new file mode 100755 index 0000000..c039b66 --- /dev/null +++ b/main.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +""" +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 ui.main_window import MainWindow + + +def main(): + app = QApplication(sys.argv) + app.setApplicationName("LabDAQ") + app.setOrganizationName("Lab Instruments") + # Note: AA_UseHighDpiPixmaps was removed in PyQt6 — HiDPI is always on + + qss_path = os.path.join(os.path.dirname(os.path.abspath(__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/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d5114b5 --- /dev/null +++ b/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/ui/__init__.py b/ui/__init__.py new file mode 100644 index 0000000..dfde25c --- /dev/null +++ b/ui/__init__.py @@ -0,0 +1 @@ +# ui/__init__.py diff --git a/ui/__pycache__/__init__.cpython-314.pyc b/ui/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..6e83590 Binary files /dev/null and b/ui/__pycache__/__init__.cpython-314.pyc differ diff --git a/ui/__pycache__/add_device_dialog.cpython-314.pyc b/ui/__pycache__/add_device_dialog.cpython-314.pyc new file mode 100644 index 0000000..280fdbf Binary files /dev/null and b/ui/__pycache__/add_device_dialog.cpython-314.pyc differ diff --git a/ui/__pycache__/alarm_panel.cpython-314.pyc b/ui/__pycache__/alarm_panel.cpython-314.pyc new file mode 100644 index 0000000..a1811f7 Binary files /dev/null and b/ui/__pycache__/alarm_panel.cpython-314.pyc differ diff --git a/ui/__pycache__/config_dialog.cpython-314.pyc b/ui/__pycache__/config_dialog.cpython-314.pyc new file mode 100644 index 0000000..8876af3 Binary files /dev/null and b/ui/__pycache__/config_dialog.cpython-314.pyc differ diff --git a/ui/__pycache__/device_panel.cpython-314.pyc b/ui/__pycache__/device_panel.cpython-314.pyc new file mode 100644 index 0000000..07eb750 Binary files /dev/null and b/ui/__pycache__/device_panel.cpython-314.pyc differ diff --git a/ui/__pycache__/main_window.cpython-314.pyc b/ui/__pycache__/main_window.cpython-314.pyc new file mode 100644 index 0000000..27e4279 Binary files /dev/null and b/ui/__pycache__/main_window.cpython-314.pyc differ diff --git a/ui/__pycache__/readout_panel.cpython-314.pyc b/ui/__pycache__/readout_panel.cpython-314.pyc new file mode 100644 index 0000000..9258b10 Binary files /dev/null and b/ui/__pycache__/readout_panel.cpython-314.pyc differ diff --git a/ui/__pycache__/strip_chart.cpython-314.pyc b/ui/__pycache__/strip_chart.cpython-314.pyc new file mode 100644 index 0000000..4af6ff5 Binary files /dev/null and b/ui/__pycache__/strip_chart.cpython-314.pyc differ diff --git a/ui/add_device_dialog.py b/ui/add_device_dialog.py new file mode 100644 index 0000000..30d0016 --- /dev/null +++ b/ui/add_device_dialog.py @@ -0,0 +1,100 @@ +""" +ui/add_device_dialog.py — Dialog to add a new device at runtime. +""" + +from PyQt6.QtWidgets import ( + QDialog, QVBoxLayout, QFormLayout, QHBoxLayout, + QComboBox, QLineEdit, QSpinBox, QCheckBox, + QPushButton, QLabel, QMessageBox, +) + +from devices.device_registry import DeviceRegistry +from devices.analog_input import AnalogInputDevice +from devices.digital_io import DigitalIODevice +from devices.serial_device import SerialDevice + + +# Map display name -> (class, extra_kwargs_defaults) +_DEVICE_TYPES = { + "Analog Input — NI-DAQmx": (AnalogInputDevice, {"backend": "nidaqmx"}), + "Analog Input — Arduino": (AnalogInputDevice, {"backend": "arduino"}), + "Digital I/O — NI-DAQmx": (DigitalIODevice, {"backend": "nidaqmx"}), + "Digital I/O — Arduino": (DigitalIODevice, {"backend": "arduino"}), + "Serial / UART": (SerialDevice, {}), +} + + +class AddDeviceDialog(QDialog): + def __init__(self, registry: DeviceRegistry, parent=None): + super().__init__(parent) + self.registry = registry + self.created_device = None + self.setWindowTitle("Add Device") + self.setMinimumWidth(360) + self._build() + + def _build(self): + layout = QVBoxLayout(self) + form = QFormLayout() + + self._type_cb = QComboBox() + self._type_cb.addItems(list(_DEVICE_TYPES.keys())) + form.addRow("Device Type:", self._type_cb) + + self._id_edit = QLineEdit() + self._id_edit.setPlaceholderText("e.g. ai_1 / ser_0") + form.addRow("Device ID:", self._id_edit) + + self._ch_spin = QSpinBox() + self._ch_spin.setRange(1, 16) + self._ch_spin.setValue(4) + form.addRow("# Channels:", self._ch_spin) + + self._sim_chk = QCheckBox("Simulation mode (no hardware required)") + self._sim_chk.setChecked(True) + form.addRow(self._sim_chk) + + layout.addLayout(form) + + btns = QHBoxLayout() + btns.addStretch() + cancel = QPushButton("Cancel") + cancel.clicked.connect(self.reject) + add = QPushButton("Add Device") + add.setDefault(True) + add.setObjectName("applyButton") + add.clicked.connect(self._on_add) + btns.addWidget(cancel) + btns.addWidget(add) + layout.addLayout(btns) + + def _on_add(self): + label = self._type_cb.currentText() + cls, kw = _DEVICE_TYPES[label] + dev_id = self._id_edit.text().strip() + + if not dev_id: + base = kw.get("backend", "dev") + existing = {d.info.device_id for d in self.registry.all_instances()} + for i in range(100): + candidate = f"{base}_{i}" + if candidate not in existing: + dev_id = candidate + break + + if self.registry.get_instance(dev_id): + QMessageBox.warning(self, "Duplicate ID", + f"A device with ID '{dev_id}' already exists.") + return + + try: + dev = cls( + device_id=dev_id, + num_channels=self._ch_spin.value(), + simulate=self._sim_chk.isChecked(), + **kw, + ) + self.created_device = dev + self.accept() + except Exception as e: + QMessageBox.critical(self, "Error creating device", str(e)) diff --git a/ui/alarm_panel.py b/ui/alarm_panel.py new file mode 100644 index 0000000..1270aff --- /dev/null +++ b/ui/alarm_panel.py @@ -0,0 +1,85 @@ +""" +ui/alarm_panel.py — Alarm event log. +""" + +from datetime import datetime +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QScrollArea, QFrame, QPushButton, +) +from PyQt6.QtCore import Qt + + +class AlarmRow(QFrame): + def __init__(self, dev_id: str, ch_id: str, value: float, kind: str): + super().__init__() + self.setObjectName("alarmEntry") + color = "#ef4444" if kind == "high" else "#f59e0b" + arrow = "▲" if kind == "high" else "▼" + self.setStyleSheet(f"QFrame#alarmEntry {{ border-left: 3px solid {color}; }}") + + ts_str = datetime.now().strftime("%H:%M:%S.%f")[:11] + layout = QHBoxLayout(self) + layout.setContentsMargins(8, 3, 8, 3) + + msg = QLabel(f"{arrow} {dev_id} / {ch_id} = {value:.4f} [{kind.upper()}]") + msg.setObjectName("alarmMsg") + ts = QLabel(ts_str) + ts.setObjectName("alarmTs") + + layout.addWidget(msg, 1) + layout.addWidget(ts) + + +class AlarmPanel(QWidget): + def __init__(self): + super().__init__() + self._count = 0 + self._build() + + def _build(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + hdr_w = QWidget() + hdr_w.setObjectName("alarmHeaderWidget") + hdr_lay = QHBoxLayout(hdr_w) + hdr_lay.setContentsMargins(0, 0, 4, 0) + hdr = QLabel(" ALARMS") + hdr.setObjectName("panelHeader") + hdr.setMinimumHeight(28) + hdr_lay.addWidget(hdr, 1) + clr = QPushButton("Clear") + clr.setObjectName("clearAlarmsBtn") + clr.clicked.connect(self.clear) + hdr_lay.addWidget(clr) + layout.addWidget(hdr_w) + + self._scroll = QScrollArea() + self._scroll.setWidgetResizable(True) + self._container = QWidget() + self._inner = QVBoxLayout(self._container) + self._inner.setContentsMargins(4, 4, 4, 4) + self._inner.setSpacing(2) + self._inner.addStretch() + self._scroll.setWidget(self._container) + layout.addWidget(self._scroll) + + def add_alarm(self, dev_id: str, ch_id: str, value: float, kind: str): + row = AlarmRow(dev_id, ch_id, value, kind) + self._inner.insertWidget(0, row) + self._count += 1 + # Cap at 300 entries + if self._count > 300: + item = self._inner.takeAt(self._inner.count() - 2) + if item and item.widget(): + item.widget().deleteLater() + self._count -= 1 + + def clear(self): + while self._inner.count() > 1: + item = self._inner.takeAt(0) + if item and item.widget(): + item.widget().deleteLater() + self._count = 0 diff --git a/ui/config_dialog.py b/ui/config_dialog.py new file mode 100644 index 0000000..7d87772 --- /dev/null +++ b/ui/config_dialog.py @@ -0,0 +1,111 @@ +""" +ui/config_dialog.py — Device configuration dialog (tabbed). +""" + +from PyQt6.QtWidgets import ( + QDialog, QVBoxLayout, QHBoxLayout, QTabWidget, + QWidget, QFormLayout, QGroupBox, QScrollArea, + QLabel, QLineEdit, QDoubleSpinBox, QCheckBox, + QPushButton, +) +from PyQt6.QtCore import Qt + + +class DeviceConfigDialog(QDialog): + def __init__(self, device, parent=None): + super().__init__(parent) + self.device = device + self.setWindowTitle(f"Configure — {device.info.name} [{device.info.device_id}]") + self.setMinimumSize(520, 460) + self._build() + + def _build(self): + layout = QVBoxLayout(self) + layout.setSpacing(8) + + tabs = QTabWidget() + + # ── Tab 1: Device-specific widget ──────────────────────────── + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setWidget(self.device.get_config_widget()) + tabs.addTab(scroll, "Hardware / Backend") + + # ── Tab 2: Channel settings ─────────────────────────────────── + tabs.addTab(self._channel_tab(), "Channels & Alarms") + + # ── Tab 3: Device info ──────────────────────────────────────── + tabs.addTab(self._info_tab(), "Info") + + layout.addWidget(tabs) + + btn_row = QHBoxLayout() + btn_row.addStretch() + close_btn = QPushButton("Close") + close_btn.setDefault(True) + close_btn.clicked.connect(self.accept) + btn_row.addWidget(close_btn) + layout.addLayout(btn_row) + + def _channel_tab(self): + w = QScrollArea() + w.setWidgetResizable(True) + container = QWidget() + layout = QVBoxLayout(container) + + for ch in self.device.info.channels: + grp = QGroupBox(f"{ch.channel_id} — {ch.name}") + form = QFormLayout(grp) + + name_e = QLineEdit(ch.name) + unit_e = QLineEdit(ch.unit) + en_chk = QCheckBox() + en_chk.setChecked(ch.enabled) + + lo = QDoubleSpinBox(); lo.setRange(-1e9, 1e9); lo.setValue(ch.alarm_low or 0.0) + hi = QDoubleSpinBox(); hi.setRange(-1e9, 1e9); hi.setValue(ch.alarm_high or 100.0) + + form.addRow("Name:", name_e) + form.addRow("Unit:", unit_e) + form.addRow("Enabled:", en_chk) + form.addRow("Alarm Low:", lo) + form.addRow("Alarm High:", hi) + + apply = QPushButton("Apply") + apply.setObjectName("applyButton") + + def _make_apply(c, ne, ue, ec, ls, hs): + def _do(): + c.name = ne.text() + c.unit = ue.text() + c.enabled = ec.isChecked() + c.alarm_low = ls.value() + c.alarm_high = hs.value() + return _do + + apply.clicked.connect(_make_apply(ch, name_e, unit_e, en_chk, lo, hi)) + form.addRow(apply) + layout.addWidget(grp) + + layout.addStretch() + w.setWidget(container) + return w + + def _info_tab(self): + w = QWidget() + form = QFormLayout(w) + info = self.device.info + + def _ro(v): + e = QLineEdit(str(v)); e.setReadOnly(True); return e + + form.addRow("Device ID:", _ro(info.device_id)) + form.addRow("Name:", _ro(info.name)) + form.addRow("Type:", _ro(info.device_type)) + form.addRow("Description:", _ro(info.description)) + form.addRow("Manufacturer:", _ro(info.manufacturer)) + form.addRow("Model:", _ro(info.model)) + form.addRow("Version:", _ro(info.version)) + form.addRow("Status:", _ro(self.device.status.value)) + form.addRow("Channels:", _ro(len(info.channels))) + return w diff --git a/ui/device_panel.py b/ui/device_panel.py new file mode 100644 index 0000000..932f536 --- /dev/null +++ b/ui/device_panel.py @@ -0,0 +1,148 @@ +""" +ui/device_panel.py — Left sidebar: device list with status indicators. +""" + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QPushButton, QScrollArea, QFrame, QSizePolicy, +) +from PyQt6.QtCore import Qt, pyqtSignal, QTimer +from devices.base_device import DeviceStatus +from devices.device_registry import DeviceRegistry +from core.acquisition import AcquisitionEngine + + +_STATUS_STYLE = { + DeviceStatus.CONNECTED: "color:#22c55e;", + DeviceStatus.SIMULATED: "color:#3b82f6;", + DeviceStatus.DISCONNECTED: "color:#475569;", + DeviceStatus.CONNECTING: "color:#f59e0b;", + DeviceStatus.ERROR: "color:#ef4444;", +} +_STATUS_LABEL = { + DeviceStatus.CONNECTED: "CONNECTED", + DeviceStatus.SIMULATED: "SIMULATED", + DeviceStatus.DISCONNECTED: "OFFLINE", + DeviceStatus.CONNECTING: "CONNECTING", + DeviceStatus.ERROR: "ERROR", +} + + +class DeviceCard(QFrame): + config_requested = pyqtSignal(str) + + def __init__(self, device): + super().__init__() + self.device = device + self.setObjectName("deviceCard") + self._build() + + def _build(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(10, 10, 10, 10) + layout.setSpacing(5) + + # Header row: icon + name + status dot + hdr = QHBoxLayout() + icon_lbl = QLabel(self.device.info.icon) + icon_lbl.setObjectName("deviceIcon") + hdr.addWidget(icon_lbl) + + name_lbl = QLabel(self.device.info.name) + name_lbl.setObjectName("deviceName") + hdr.addWidget(name_lbl, 1) + + self._dot = QLabel("●") + self._dot.setStyleSheet(_STATUS_STYLE.get(self.device.status, "color:#475569;") + " font-size:10px;") + hdr.addWidget(self._dot) + layout.addLayout(hdr) + + # Device ID + id_lbl = QLabel(self.device.info.device_id) + id_lbl.setObjectName("deviceSub") + layout.addWidget(id_lbl) + + # Status text + self._status_lbl = QLabel(_STATUS_LABEL.get(self.device.status, "UNKNOWN")) + style = _STATUS_STYLE.get(self.device.status, "color:#475569;") + self._status_lbl.setStyleSheet( + style + " font-family:'IBM Plex Mono',monospace; font-size:10px; font-weight:700; letter-spacing:1px;" + ) + layout.addWidget(self._status_lbl) + + # Channel count + n = sum(1 for c in self.device.info.channels if c.enabled) + ch_lbl = QLabel(f"{n} ch · {self.device.info.device_type}") + ch_lbl.setObjectName("deviceChannelCount") + layout.addWidget(ch_lbl) + + # Config button + cfg = QPushButton("Configure") + cfg.setObjectName("configButton") + cfg.clicked.connect(lambda: self.config_requested.emit(self.device.info.device_id)) + layout.addWidget(cfg) + + def refresh(self): + dot_style = _STATUS_STYLE.get(self.device.status, "color:#475569;") + " font-size:10px;" + label_style = _STATUS_STYLE.get(self.device.status, "color:#475569;") + \ + " font-family:'IBM Plex Mono',monospace; font-size:10px; font-weight:700; letter-spacing:1px;" + self._dot.setStyleSheet(dot_style) + self._status_lbl.setText(_STATUS_LABEL.get(self.device.status, "UNKNOWN")) + self._status_lbl.setStyleSheet(label_style) + + +class DevicePanel(QWidget): + config_requested = pyqtSignal(str) + + def __init__(self, registry: DeviceRegistry, engine: AcquisitionEngine): + super().__init__() + self.registry = registry + self.engine = engine + self._cards = {} + self._build() + + # Refresh status every 2 s + self._timer = QTimer(self) + self._timer.setInterval(2000) + self._timer.timeout.connect(self._refresh_status) + self._timer.start() + + def _build(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + hdr = QLabel(" DEVICES") + hdr.setObjectName("panelHeader") + hdr.setMinimumHeight(28) + layout.addWidget(hdr) + + scroll = QScrollArea() + scroll.setObjectName("deviceScroll") + scroll.setWidgetResizable(True) + scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + + self._container = QWidget() + self._inner = QVBoxLayout(self._container) + self._inner.setContentsMargins(8, 8, 8, 8) + self._inner.setSpacing(8) + self._inner.addStretch() + + scroll.setWidget(self._container) + layout.addWidget(scroll) + + def refresh(self): + for card in self._cards.values(): + self._inner.removeWidget(card) + card.deleteLater() + self._cards.clear() + + for dev in self.registry.all_instances(): + card = DeviceCard(dev) + card.config_requested.connect(self.config_requested.emit) + self._inner.insertWidget(self._inner.count() - 1, card) + self._cards[dev.info.device_id] = card + + def _refresh_status(self): + for dev_id, card in self._cards.items(): + card.refresh() diff --git a/ui/main_window.py b/ui/main_window.py new file mode 100644 index 0000000..d329571 --- /dev/null +++ b/ui/main_window.py @@ -0,0 +1,214 @@ +""" +ui/main_window.py — Main application window. + +Layout: + ┌──────────────────────────────────────────────────────────┐ + │ TOOLBAR [▶ RUN] [⬤ LOG] [+ Device] 00:00:00 │ + ├────────────┬───────────────────────────┬─────────────────┤ + │ DEVICES │ STRIP CHART (pyqtgraph) │ CHANNELS │ + │ (left) │ │ (readouts) │ + │ │ ├─────────────────┤ + │ │ │ ALARMS │ + └────────────┴───────────────────────────┴─────────────────┘ + │ status bar │ + └──────────────────────────────────────────────────────────┘ +""" + +from PyQt6.QtWidgets import ( + QMainWindow, QWidget, QHBoxLayout, QVBoxLayout, + QSplitter, QStatusBar, QLabel, QPushButton, + QToolBar, QSizePolicy, QMessageBox, +) +from PyQt6.QtCore import Qt, QTimer, pyqtSlot + +from devices.analog_input import AnalogInputDevice +from devices.digital_io import DigitalIODevice +from devices.serial_device import SerialDevice +from devices.device_registry import DeviceRegistry +from core.acquisition import AcquisitionEngine + +from ui.device_panel import DevicePanel +from ui.strip_chart import StripChartWidget +from ui.readout_panel import ReadoutPanel +from ui.alarm_panel import AlarmPanel +from ui.config_dialog import DeviceConfigDialog +from ui.add_device_dialog import AddDeviceDialog + + +class MainWindow(QMainWindow): + def __init__(self): + super().__init__() + self.setWindowTitle("LabDAQ — Data Acquisition System") + self.setMinimumSize(1360, 820) + + self.registry = DeviceRegistry() + self.engine = AcquisitionEngine(poll_interval_ms=100) + + self._elapsed = 0 + self._running = False + + self._init_demo_devices() + self._build_ui() + self._connect_signals() + + def _init_demo_devices(self): + ai = AnalogInputDevice(device_id="ai_0", num_channels=4, simulate=True, backend="nidaqmx") + ai.connect() + ard = AnalogInputDevice(device_id="ard_0", num_channels=4, simulate=True, backend="arduino") + ard.connect() + dio = DigitalIODevice(device_id="dio_0", num_inputs=4, num_outputs=4, simulate=True, backend="nidaqmx") + dio.connect() + ser = SerialDevice(device_id="ser_0", num_channels=3, simulate=True) + ser.connect() + for dev in [ai, ard, dio, ser]: + self.registry.add_instance(dev) + self.engine.add_device(dev) + + def _build_ui(self): + tb = QToolBar("Main") + tb.setObjectName("mainToolbar") + tb.setMovable(False) + self.addToolBar(tb) + + self._run_btn = QPushButton("▶ RUN") + self._run_btn.setObjectName("runButton") + self._run_btn.setCheckable(True) + self._run_btn.clicked.connect(self._toggle_run) + tb.addWidget(self._run_btn) + tb.addSeparator() + + self._log_btn = QPushButton("⬤ LOG") + self._log_btn.setObjectName("logButton") + self._log_btn.setCheckable(True) + self._log_btn.setEnabled(False) + self._log_btn.clicked.connect(self._toggle_log) + tb.addWidget(self._log_btn) + tb.addSeparator() + + add_btn = QPushButton("+ Device") + add_btn.setObjectName("addDeviceButton") + add_btn.clicked.connect(self._add_device) + tb.addWidget(add_btn) + + spacer = QWidget() + spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + tb.addWidget(spacer) + + self._time_lbl = QLabel("00:00:00") + self._time_lbl.setObjectName("timeLabel") + tb.addWidget(self._time_lbl) + + central = QWidget() + self.setCentralWidget(central) + root = QHBoxLayout(central) + root.setContentsMargins(0, 0, 0, 0) + root.setSpacing(0) + + hsplit = QSplitter(Qt.Orientation.Horizontal) + hsplit.setHandleWidth(3) + + self._dev_panel = DevicePanel(self.registry, self.engine) + self._dev_panel.setMinimumWidth(210) + self._dev_panel.setMaximumWidth(300) + hsplit.addWidget(self._dev_panel) + + self._chart = StripChartWidget(self.engine, self.registry) + hsplit.addWidget(self._chart) + + right_widget = QWidget() + right_widget.setMinimumWidth(230) + right_widget.setMaximumWidth(320) + right_lay = QVBoxLayout(right_widget) + right_lay.setContentsMargins(0, 0, 0, 0) + right_lay.setSpacing(0) + vsplit = QSplitter(Qt.Orientation.Vertical) + vsplit.setHandleWidth(3) + self._readout_panel = ReadoutPanel(self.registry) + self._alarm_panel = AlarmPanel() + vsplit.addWidget(self._readout_panel) + vsplit.addWidget(self._alarm_panel) + vsplit.setSizes([500, 300]) + right_lay.addWidget(vsplit) + hsplit.addWidget(right_widget) + + hsplit.setSizes([230, 880, 260]) + root.addWidget(hsplit) + + sb = QStatusBar() + self.setStatusBar(sb) + self._status_lbl = QLabel("Ready — simulation mode active") + sb.addWidget(self._status_lbl) + self._log_lbl = QLabel("") + sb.addPermanentWidget(self._log_lbl) + + self._clock = QTimer(self) + self._clock.setInterval(1000) + self._clock.timeout.connect(self._tick) + + def _connect_signals(self): + self.engine.alarm_triggered.connect(self._on_alarm) + self.engine.new_data.connect(self._readout_panel.on_new_data) + self.engine.new_data.connect(self._chart.on_new_data) + self.engine.log_started.connect(lambda p: self._log_lbl.setText(f"● LOG {p}")) + self.engine.log_stopped.connect(lambda p: self._log_lbl.setText(f"✓ Saved {p}")) + self._dev_panel.config_requested.connect(self._open_config) + + def _toggle_run(self, checked: bool): + if checked: + self.engine.start() + self._run_btn.setText("⏹ STOP") + self._log_btn.setEnabled(True) + self._clock.start() + self._status_lbl.setText("Acquiring…") + self._running = True + else: + self.engine.stop() + self._run_btn.setText("▶ RUN") + if self._log_btn.isChecked(): + self._log_btn.setChecked(False) + self._log_btn.setEnabled(False) + self._clock.stop() + self._status_lbl.setText("Stopped") + self._running = False + + def _toggle_log(self, checked: bool): + if checked: + path = self.engine.start_logging() + self._log_btn.setText("⏹ LOGGING") + self._status_lbl.setText(f"Logging → {path}") + else: + self.engine.stop_logging() + self._log_btn.setText("⬤ LOG") + + def _add_device(self): + dlg = AddDeviceDialog(self.registry, self) + if dlg.exec(): + dev = dlg.created_device + if dev: + dev.connect() + self.registry.add_instance(dev) + self.engine.add_device(dev) + self._dev_panel.refresh() + self._readout_panel.refresh() + self._chart.refresh() + self._status_lbl.setText(f"Added device: {dev.info.device_id}") + + def _open_config(self, device_id: str): + dev = self.registry.get_instance(device_id) + if dev: + DeviceConfigDialog(dev, self).exec() + + @pyqtSlot(str, str, float, str) + def _on_alarm(self, dev_id: str, ch_id: str, value: float, kind: str): + self._alarm_panel.add_alarm(dev_id, ch_id, value, kind) + + def _tick(self): + self._elapsed += 1 + h = self._elapsed // 3600 + m = (self._elapsed % 3600) // 60 + s = self._elapsed % 60 + self._time_lbl.setText(f"{h:02d}:{m:02d}:{s:02d}") + + def closeEvent(self, event): + self.engine.stop() + event.accept() diff --git a/ui/readout_panel.py b/ui/readout_panel.py new file mode 100644 index 0000000..ef04e18 --- /dev/null +++ b/ui/readout_panel.py @@ -0,0 +1,122 @@ +""" +ui/readout_panel.py — Right-side numeric readout panel. +""" + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QScrollArea, QFrame, QProgressBar, +) +from PyQt6.QtCore import Qt, pyqtSlot +from devices.device_registry import DeviceRegistry + + +class ChannelReadout(QFrame): + def __init__(self, ch_config): + super().__init__() + self.ch = ch_config + self.setObjectName("channelReadout") + self._alarm = False + self._build() + + def _build(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(8, 6, 8, 6) + layout.setSpacing(2) + + top = QHBoxLayout() + self._name = QLabel(self.ch.name) + self._name.setObjectName("readoutName") + top.addWidget(self._name, 1) + + self._val = QLabel("— — —") + self._val.setObjectName("readoutValue") + self._val.setStyleSheet(f"color:{self.ch.color};") + top.addWidget(self._val) + + unit = QLabel(f" {self.ch.unit}") + unit.setObjectName("readoutUnit") + top.addWidget(unit) + layout.addLayout(top) + + self._bar = QProgressBar() + self._bar.setObjectName("readoutBar") + self._bar.setRange(0, 1000) + self._bar.setValue(500) + self._bar.setTextVisible(False) + self._bar.setMaximumHeight(3) + self._bar.setStyleSheet( + f"QProgressBar::chunk {{ background:{self.ch.color}; border-radius:1px; }}" + ) + layout.addWidget(self._bar) + + def update_value(self, v: float): + self._val.setText(f"{v:>10.4f}".strip()) + rng = self.ch.max_value - self.ch.min_value + norm = int(((v - self.ch.min_value) / rng) * 1000) if rng else 500 + self._bar.setValue(max(0, min(1000, norm))) + + alarm = ( + (self.ch.alarm_low is not None and v < self.ch.alarm_low) or + (self.ch.alarm_high is not None and v > self.ch.alarm_high) + ) + if alarm != self._alarm: + self._alarm = alarm + self.setProperty("alarm", alarm) + self.style().unpolish(self) + self.style().polish(self) + + +class ReadoutPanel(QWidget): + def __init__(self, registry: DeviceRegistry): + super().__init__() + self.registry = registry + self._readouts = {} # (dev_id, ch_id) -> ChannelReadout + self._build() + self.refresh() + + def _build(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + hdr = QLabel(" CHANNELS") + hdr.setObjectName("panelHeader") + hdr.setMinimumHeight(28) + layout.addWidget(hdr) + + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + + self._container = QWidget() + self._inner = QVBoxLayout(self._container) + self._inner.setContentsMargins(6, 6, 6, 6) + self._inner.setSpacing(4) + self._inner.addStretch() + + scroll.setWidget(self._container) + layout.addWidget(scroll) + + def refresh(self): + for w in self._readouts.values(): + w.deleteLater() + self._readouts.clear() + # Clear layout + while self._inner.count() > 1: + item = self._inner.takeAt(0) + if item and item.widget(): + item.widget().deleteLater() + + for dev in self.registry.all_instances(): + for ch in dev.info.channels: + if not ch.enabled: + continue + ro = ChannelReadout(ch) + self._inner.insertWidget(self._inner.count() - 1, ro) + self._readouts[(dev.info.device_id, ch.channel_id)] = ro + + @pyqtSlot(str, str, float, float) + def on_new_data(self, dev_id: str, ch_id: str, _ts: float, value: float): + ro = self._readouts.get((dev_id, ch_id)) + if ro: + ro.update_value(value) diff --git a/ui/strip_chart.py b/ui/strip_chart.py new file mode 100644 index 0000000..afc7cf7 --- /dev/null +++ b/ui/strip_chart.py @@ -0,0 +1,186 @@ +""" +ui/strip_chart.py + +Live scrolling strip chart. One pyqtgraph plot per device, +all time-axes linked. Per-channel colored traces with legend. +""" + +import numpy as np +from typing import Dict + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QDoubleSpinBox, QPushButton, QSizePolicy, QComboBox, +) +from PyQt6.QtCore import Qt, pyqtSlot + +try: + import pyqtgraph as pg + pg.setConfigOptions(antialias=True, background="#0b0e13", foreground="#475569") + _HAS_PG = True +except ImportError: + _HAS_PG = False + +from devices.device_registry import DeviceRegistry +from core.acquisition import AcquisitionEngine + + +class StripChartWidget(QWidget): + def __init__(self, engine: AcquisitionEngine, registry: DeviceRegistry): + super().__init__() + self.engine = engine + self.registry = registry + self._window = 30.0 + self._paused = False + self._plots: Dict[str, Dict] = {} # dev_id -> {ch_id -> {curve, plot}} + self._build() + self.refresh() + + # ── Build ──────────────────────────────────────────────────────────── + + def _build(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(6, 6, 6, 4) + layout.setSpacing(4) + + # Control bar + ctrl = QHBoxLayout() + ctrl.addWidget(QLabel("Window:")) + + self._win_spin = QDoubleSpinBox() + self._win_spin.setRange(1.0, 3600.0) + self._win_spin.setValue(self._window) + self._win_spin.setSuffix(" s") + self._win_spin.valueChanged.connect(self._on_window_changed) + ctrl.addWidget(self._win_spin) + ctrl.addSpacing(16) + + ctrl.addWidget(QLabel("Y-Scale:")) + self._scale_cb = QComboBox() + self._scale_cb.addItems(["Auto", "Fixed"]) + self._scale_cb.currentTextChanged.connect(self._on_scale_changed) + ctrl.addWidget(self._scale_cb) + + ctrl.addStretch() + + self._pause_btn = QPushButton("⏸ Pause") + self._pause_btn.setCheckable(True) + self._pause_btn.setObjectName("pauseButton") + self._pause_btn.toggled.connect(self._on_pause) + ctrl.addWidget(self._pause_btn) + + layout.addLayout(ctrl) + + if _HAS_PG: + self._gw = pg.GraphicsLayoutWidget() + self._gw.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding + ) + layout.addWidget(self._gw) + else: + layout.addWidget(QLabel( + "⚠ pyqtgraph not installed.\n\npip install pyqtgraph\n\nData is still acquired & logged.", + alignment=Qt.AlignmentFlag.AlignCenter, + )) + + # ── Refresh (rebuild plots after device list changes) ───────────────── + + def refresh(self): + if not _HAS_PG: + return + self._gw.clear() + self._plots.clear() + + devs = self.registry.all_instances() + n_devs = len(devs) + if n_devs == 0: + return + + ref_plot = None # for x-axis linking + + for row, dev in enumerate(devs): + plot = self._gw.addPlot(row=row, col=0) + plot.setLabel("left", f"{dev.info.icon} {dev.info.name}") + plot.showGrid(x=True, y=True, alpha=0.12) + plot.getAxis("left").setStyle(tickFont=self._mono_font()) + plot.getAxis("bottom").setStyle(tickFont=self._mono_font()) + + if row < n_devs - 1: + plot.getAxis("bottom").setStyle(showValues=False) + plot.getAxis("bottom").setHeight(0) + else: + plot.setLabel("bottom", "Elapsed (s)") + + if ref_plot is not None: + plot.setXLink(ref_plot) + ref_plot = plot + + legend = plot.addLegend( + offset=(5, 5), + labelTextColor="#94a3b8", + brush=pg.mkBrush("#161d2e"), + pen=pg.mkPen("#2a3558"), + ) + + self._plots[dev.info.device_id] = {} + for ch in dev.info.channels: + if not ch.enabled: + continue + pen = pg.mkPen(color=ch.color, width=1.8) + curve = plot.plot([], [], pen=pen, name=ch.name) + self._plots[dev.info.device_id][ch.channel_id] = { + "curve": curve, + "plot": plot, + } + + @staticmethod + def _mono_font(): + from PyQt6.QtGui import QFont + f = QFont("IBM Plex Mono", 8) + return f + + # ── Slots ──────────────────────────────────────────────────────────── + + @pyqtSlot(str, str, float, float) + def on_new_data(self, device_id: str, channel_id: str, timestamp: float, value: float): + if self._paused or not _HAS_PG: + return + dev_plots = self._plots.get(device_id) + if dev_plots is None: + return + entry = dev_plots.get(channel_id) + if entry is None: + return + + buf = self.engine.get_buffer(device_id, channel_id) + if buf is None or len(buf) < 2: + return + + ts, vs = buf.window(self._window) + if not ts: + return + ts_arr = np.array(ts, dtype=np.float64) + vs_arr = np.array(vs, dtype=np.float64) + + entry["curve"].setData(ts_arr, vs_arr) + t_max = ts_arr[-1] + t_min = t_max - self._window + entry["plot"].setXRange(t_min, t_max, padding=0) + + if self._scale_cb.currentText() == "Auto": + entry["plot"].enableAutoRange(axis="y") + + def _on_window_changed(self, v: float): + self._window = v + + def _on_pause(self, checked: bool): + self._paused = checked + self._pause_btn.setText("▶ Resume" if checked else "⏸ Pause") + + def _on_scale_changed(self, text: str): + if not _HAS_PG: + return + if text == "Auto": + for dev_plots in self._plots.values(): + for entry in dev_plots.values(): + entry["plot"].enableAutoRange(axis="y") diff --git a/ui/style.qss b/ui/style.qss new file mode 100644 index 0000000..60415fb --- /dev/null +++ b/ui/style.qss @@ -0,0 +1,378 @@ +/* LabDAQ — Industrial Dark Theme + Font stack: IBM Plex Mono (monospace data), IBM Plex Sans (UI labels) + Palette: + bg-base #0b0e13 + bg-panel #111620 + bg-card #161d2e + bg-raised #1c2540 + border #2a3558 + accent-blue #3b82f6 + accent-cyan #00d4ff + accent-green #22c55e + text-primary #e2e8f0 + text-muted #64748b + danger #ef4444 + warning #f59e0b +*/ + +* { + font-family: "IBM Plex Sans", "Segoe UI", Tahoma, sans-serif; + font-size: 12px; + color: #e2e8f0; +} + +QMainWindow, QDialog { + background-color: #0b0e13; +} + +/* ── Toolbar ─────────────────────────────────────────────────── */ +QToolBar#mainToolbar { + background-color: #0b0e13; + border-bottom: 1px solid #2a3558; + padding: 4px 8px; + spacing: 6px; +} + +QPushButton#runButton { + background-color: #166534; + color: #dcfce7; + border: 1px solid #22c55e; + border-radius: 4px; + padding: 5px 16px; + font-family: "IBM Plex Mono", monospace; + font-weight: 600; + letter-spacing: 0.5px; + min-width: 90px; +} +QPushButton#runButton:checked { + background-color: #7f1d1d; + border-color: #ef4444; + color: #fee2e2; +} +QPushButton#runButton:hover { background-color: #15803d; } +QPushButton#runButton:checked:hover { background-color: #991b1b; } + +QPushButton#logButton { + background-color: #1c2540; + color: #94a3b8; + border: 1px solid #2a3558; + border-radius: 4px; + padding: 5px 14px; + font-family: "IBM Plex Mono", monospace; + min-width: 80px; +} +QPushButton#logButton:enabled { + color: #e2e8f0; + border-color: #3b82f6; +} +QPushButton#logButton:checked { + background-color: #7c2d12; + border-color: #ef4444; + color: #fee2e2; +} + +QPushButton#addDeviceButton { + background-color: #1e3a5f; + color: #93c5fd; + border: 1px solid #3b82f6; + border-radius: 4px; + padding: 5px 14px; +} +QPushButton#addDeviceButton:hover { + background-color: #1d4ed8; + color: #eff6ff; +} + +QLabel#timeLabel { + font-family: "IBM Plex Mono", monospace; + font-size: 16px; + font-weight: 700; + color: #00d4ff; + letter-spacing: 2px; + padding-right: 8px; +} + +/* ── Panels & Headers ────────────────────────────────────────── */ +QLabel#panelHeader { + background-color: #0f1521; + color: #64748b; + font-family: "IBM Plex Mono", monospace; + font-size: 10px; + font-weight: 700; + letter-spacing: 2px; + padding: 6px 0px; + border-bottom: 1px solid #2a3558; +} + +QWidget#alarmHeaderWidget { + background-color: #0f1521; + border-bottom: 1px solid #2a3558; +} + +/* ── Device Cards ────────────────────────────────────────────── */ +QScrollArea#deviceScroll { + background-color: #0b0e13; + border: none; + border-right: 1px solid #2a3558; +} + +QFrame#deviceCard { + background-color: #161d2e; + border: 1px solid #2a3558; + border-radius: 6px; +} +QFrame#deviceCard:hover { + border-color: #3b82f6; + background-color: #1c2540; +} + +QLabel#deviceIcon { + font-size: 18px; +} +QLabel#deviceName { + font-size: 13px; + font-weight: 600; + color: #e2e8f0; +} +QLabel#deviceSub { + font-family: "IBM Plex Mono", monospace; + font-size: 10px; + color: #64748b; +} +QLabel#deviceChannelCount { + font-size: 11px; + color: #3b82f6; +} + +QPushButton#configButton { + background-color: #1c2540; + color: #64748b; + border: 1px solid #2a3558; + border-radius: 3px; + padding: 3px 10px; + font-size: 11px; +} +QPushButton#configButton:hover { + background-color: #1e3a5f; + color: #93c5fd; + border-color: #3b82f6; +} + +/* ── Channel Readouts ────────────────────────────────────────── */ +QFrame#channelReadout { + background-color: #161d2e; + border: 1px solid #1c2540; + border-radius: 4px; +} +QFrame#channelReadout[alarm="true"] { + border-color: #ef4444; + background-color: #200d0d; +} +QLabel#readoutName { + font-size: 11px; + color: #64748b; +} +QLabel#readoutValue { + font-family: "IBM Plex Mono", monospace; + font-size: 15px; + font-weight: 700; +} +QLabel#readoutUnit { + font-family: "IBM Plex Mono", monospace; + font-size: 10px; + color: #475569; +} +QProgressBar#readoutBar { + background-color: #1c2540; + border: none; + border-radius: 2px; +} + +/* ── Alarm Panel ─────────────────────────────────────────────── */ +QFrame#alarmEntry { + background-color: #161d2e; + border-radius: 3px; + border: none; +} +QLabel#alarmMsg { + font-family: "IBM Plex Mono", monospace; + font-size: 11px; + color: #e2e8f0; +} +QLabel#alarmTs { + font-family: "IBM Plex Mono", monospace; + font-size: 10px; + color: #475569; +} +QPushButton#clearAlarmsBtn { + background: transparent; + color: #64748b; + border: none; + font-size: 11px; + padding: 4px 8px; +} +QPushButton#clearAlarmsBtn:hover { color: #ef4444; } + +/* ── Strip Chart controls ────────────────────────────────────── */ +QPushButton#pauseButton { + background-color: #1c2540; + color: #64748b; + border: 1px solid #2a3558; + border-radius: 4px; + padding: 4px 12px; +} +QPushButton#pauseButton:checked { + background-color: #713f12; + border-color: #f59e0b; + color: #fef3c7; +} + +/* ── Config / Dialog ─────────────────────────────────────────── */ +QDialog { + background-color: #111620; +} +QTabWidget::pane { + background-color: #111620; + border: 1px solid #2a3558; + border-radius: 4px; +} +QTabBar::tab { + background-color: #0b0e13; + color: #64748b; + border: 1px solid #2a3558; + padding: 6px 16px; + margin-right: 2px; +} +QTabBar::tab:selected { + background-color: #1c2540; + color: #e2e8f0; + border-bottom: 2px solid #3b82f6; +} + +QGroupBox { + border: 1px solid #2a3558; + border-radius: 4px; + margin-top: 12px; + padding-top: 8px; + color: #64748b; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.5px; +} +QGroupBox::title { + subcontrol-origin: margin; + left: 8px; + top: -6px; + background-color: #111620; + padding: 0 4px; +} + +QLineEdit, QDoubleSpinBox, QSpinBox, QComboBox { + background-color: #0b0e13; + border: 1px solid #2a3558; + border-radius: 3px; + padding: 4px 8px; + color: #e2e8f0; + font-family: "IBM Plex Mono", monospace; + min-height: 22px; +} +QLineEdit:focus, QDoubleSpinBox:focus, QSpinBox:focus, QComboBox:focus { + border-color: #3b82f6; +} +QComboBox::drop-down { border: none; width: 20px; } +QComboBox QAbstractItemView { + background-color: #161d2e; + border: 1px solid #3b82f6; + selection-background-color: #1e3a5f; +} + +QCheckBox { + color: #94a3b8; + spacing: 6px; +} +QCheckBox::indicator { + width: 14px; height: 14px; + border: 1px solid #2a3558; + border-radius: 3px; + background-color: #0b0e13; +} +QCheckBox::indicator:checked { + background-color: #3b82f6; + border-color: #3b82f6; +} + +QPushButton#applyButton { + background-color: #1e3a5f; + color: #93c5fd; + border: 1px solid #3b82f6; + border-radius: 4px; + padding: 6px 20px; + font-weight: 600; +} +QPushButton#applyButton:hover { + background-color: #1d4ed8; + color: #eff6ff; +} + +/* ── Digital output toggle ───────────────────────────────────── */ +QPushButton#digitalOutBtn { + background-color: #1c2540; + color: #64748b; + border: 1px solid #2a3558; + border-radius: 4px; + padding: 4px 14px; + font-family: "IBM Plex Mono", monospace; + min-width: 50px; +} +QPushButton#digitalOutBtn:checked { + background-color: #166534; + border-color: #22c55e; + color: #dcfce7; +} + +/* ── Scrollbars ──────────────────────────────────────────────── */ +QScrollBar:vertical { + background: #0b0e13; + width: 6px; + margin: 0; +} +QScrollBar::handle:vertical { + background: #2a3558; + border-radius: 3px; + min-height: 20px; +} +QScrollBar::handle:vertical:hover { background: #3b82f6; } +QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; } +QScrollBar:horizontal { height: 6px; background: #0b0e13; } +QScrollBar::handle:horizontal { background: #2a3558; border-radius: 3px; min-width: 20px; } + +/* ── Splitter ────────────────────────────────────────────────── */ +QSplitter::handle { + background-color: #2a3558; +} +QSplitter::handle:hover { + background-color: #3b82f6; +} + +/* ── Status bar ──────────────────────────────────────────────── */ +QStatusBar { + background-color: #0b0e13; + border-top: 1px solid #2a3558; + color: #475569; + font-family: "IBM Plex Mono", monospace; + font-size: 11px; +} + +/* ── General button default ──────────────────────────────────── */ +QPushButton { + background-color: #1c2540; + color: #94a3b8; + border: 1px solid #2a3558; + border-radius: 4px; + padding: 5px 12px; +} +QPushButton:hover { background-color: #1e3a5f; color: #e2e8f0; } +QPushButton:pressed { background-color: #172554; } +QPushButton:disabled { color: #334155; border-color: #1c2540; } + +QLabel { color: #94a3b8; } -- cgit v1.2.3