summaryrefslogtreecommitdiff
path: root/daq_system
diff options
context:
space:
mode:
Diffstat (limited to 'daq_system')
-rw-r--r--daq_system/core/__init__.py1
-rw-r--r--daq_system/core/__pycache__/__init__.cpython-312.pycbin161 -> 0 bytes
-rw-r--r--daq_system/core/__pycache__/acquisition.cpython-312.pycbin11381 -> 0 bytes
-rw-r--r--daq_system/core/acquisition.py214
-rw-r--r--daq_system/daq_system/README.md183
-rw-r--r--daq_system/daq_system/api_layers/__init__.py5
-rw-r--r--daq_system/daq_system/api_layers/arduino_layer.py295
-rw-r--r--daq_system/daq_system/api_layers/nidaqmx_layer.py181
-rw-r--r--daq_system/daq_system/core/__init__.py3
-rw-r--r--daq_system/daq_system/core/acquisition.py225
-rw-r--r--daq_system/daq_system/devices/__init__.py5
-rw-r--r--daq_system/daq_system/devices/analog_input.py276
-rw-r--r--daq_system/daq_system/devices/base_device.py104
-rw-r--r--daq_system/daq_system/devices/device_registry.py73
-rw-r--r--daq_system/daq_system/devices/digital_io.py261
-rw-r--r--daq_system/daq_system/devices/serial_device.py187
-rw-r--r--daq_system/daq_system/main.py40
-rw-r--r--daq_system/daq_system/requirements.txt8
-rw-r--r--daq_system/daq_system/ui/__init__.py1
-rw-r--r--daq_system/daq_system/ui/add_device_dialog.py100
-rw-r--r--daq_system/daq_system/ui/alarm_panel.py85
-rw-r--r--daq_system/daq_system/ui/config_dialog.py111
-rw-r--r--daq_system/daq_system/ui/device_panel.py148
-rw-r--r--daq_system/daq_system/ui/main_window.py214
-rw-r--r--daq_system/daq_system/ui/readout_panel.py122
-rw-r--r--daq_system/daq_system/ui/strip_chart.py186
-rw-r--r--daq_system/daq_system/ui/style.qss378
-rw-r--r--daq_system/devices/__init__.py1
-rw-r--r--daq_system/devices/__pycache__/__init__.cpython-312.pycbin164 -> 0 bytes
-rw-r--r--daq_system/devices/__pycache__/analog_input.cpython-312.pycbin9034 -> 0 bytes
-rw-r--r--daq_system/devices/__pycache__/base_device.cpython-312.pycbin6014 -> 0 bytes
-rw-r--r--daq_system/devices/__pycache__/device_registry.cpython-312.pycbin5219 -> 0 bytes
-rw-r--r--daq_system/devices/__pycache__/digital_io.cpython-312.pycbin6027 -> 0 bytes
-rw-r--r--daq_system/devices/__pycache__/serial_device.cpython-312.pycbin7405 -> 0 bytes
-rw-r--r--daq_system/devices/__pycache__/temperature.cpython-312.pycbin6707 -> 0 bytes
-rw-r--r--daq_system/devices/analog_input.py184
-rw-r--r--daq_system/devices/base_device.py127
-rw-r--r--daq_system/devices/device_registry.py87
-rw-r--r--daq_system/devices/digital_io.py95
-rw-r--r--daq_system/devices/serial_device.py136
-rw-r--r--daq_system/devices/temperature.py115
-rw-r--r--daq_system/main.py28
-rw-r--r--daq_system/ui/__pycache__/alarm_panel.cpython-312.pycbin5462 -> 0 bytes
-rw-r--r--daq_system/ui/__pycache__/config_dialog.cpython-312.pycbin9345 -> 0 bytes
-rw-r--r--daq_system/ui/__pycache__/device_panel.cpython-312.pycbin7943 -> 0 bytes
-rw-r--r--daq_system/ui/__pycache__/main_window.cpython-312.pycbin13127 -> 0 bytes
-rw-r--r--daq_system/ui/__pycache__/readout_panel.cpython-312.pycbin8378 -> 0 bytes
-rw-r--r--daq_system/ui/__pycache__/strip_chart.cpython-312.pycbin8328 -> 0 bytes
-rw-r--r--daq_system/ui/add_device_dialog.py7
-rw-r--r--daq_system/ui/alarm_panel.py85
-rw-r--r--daq_system/ui/config_dialog.py174
-rw-r--r--daq_system/ui/device_panel.py127
-rw-r--r--daq_system/ui/main_window.py228
-rw-r--r--daq_system/ui/readout_panel.py128
-rw-r--r--daq_system/ui/strip_chart.py140
55 files changed, 0 insertions, 5068 deletions
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
--- a/daq_system/core/__pycache__/__init__.cpython-312.pyc
+++ /dev/null
Binary files 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
--- a/daq_system/core/__pycache__/acquisition.cpython-312.pyc
+++ /dev/null
Binary files 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"<ArduinoLayer {mode} pins={self.analog_pins}>"
-
-
-# ════════════════════════════════════════════════════════════════════════════
-# Arduino Firmware Reference Sketch
-# ════════════════════════════════════════════════════════════════════════════
-ARDUINO_SKETCH = """
-/*
- * LabDAQ Arduino Firmware
- * Upload this to your Arduino to communicate with the Python DAQ system.
- *
- * Protocol:
- * SEND (Arduino → PC): "A0:3.14,A1:2.71,A2:1.41,D2:1,D3:0\\n"
- * RECV (PC → Arduino): "W:D13:1\\n" to set digital outputs
- *
- * Analog values are converted from 10-bit ADC (0-1023) to 0.0-5.0 V.
- */
-
-const int ANALOG_PINS[] = {A0, A1, A2, A3, A4, A5};
-const int DIGITAL_IN[] = {2, 3, 4};
-const int DIGITAL_OUT[] = {5, 6, 7, 13};
-const int N_ANALOG = 6;
-const int N_DIG_IN = 3;
-const int N_DIG_OUT = 4;
-const int SEND_INTERVAL = 50; // ms between transmissions
-
-unsigned long lastSend = 0;
-
-void setup() {
- Serial.begin(115200);
- for (int i = 0; i < N_DIG_IN; i++) pinMode(DIGITAL_IN[i], INPUT_PULLUP);
- for (int i = 0; i < N_DIG_OUT; i++) pinMode(DIGITAL_OUT[i], OUTPUT);
-}
-
-void loop() {
- // ── Handle incoming commands ────────────────────────────
- if (Serial.available()) {
- String cmd = Serial.readStringUntil('\\n');
- cmd.trim();
- if (cmd.startsWith("W:")) {
- // W:D13:1 → set pin 13 HIGH
- int colon1 = cmd.indexOf(':', 2);
- int colon2 = cmd.indexOf(':', colon1 + 1);
- if (colon1 > 0 && colon2 > 0) {
- String pinStr = cmd.substring(colon1 + 1, colon2);
- int val = cmd.substring(colon2 + 1).toInt();
- int pin = pinStr.substring(1).toInt(); // strip 'D'
- digitalWrite(pin, val ? HIGH : LOW);
- }
- }
- }
-
- // ── Transmit data ────────────────────────────────────────
- unsigned long now = millis();
- if (now - lastSend >= SEND_INTERVAL) {
- lastSend = now;
- String out = "";
- for (int i = 0; i < N_ANALOG; i++) {
- float v = analogRead(ANALOG_PINS[i]) * (5.0 / 1023.0);
- out += "A" + String(i) + ":" + String(v, 3);
- if (i < N_ANALOG - 1) out += ",";
- }
- for (int i = 0; i < N_DIG_IN; i++) {
- out += ",D" + String(DIGITAL_IN[i]) + ":" + String(!digitalRead(DIGITAL_IN[i]));
- }
- Serial.println(out);
- }
-}
-*/
-"""
diff --git a/daq_system/daq_system/api_layers/nidaqmx_layer.py b/daq_system/daq_system/api_layers/nidaqmx_layer.py
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"<NidaqmxLayer {self.device_name} ch={self.channels} [{mode}]>"
diff --git a/daq_system/daq_system/core/__init__.py b/daq_system/daq_system/core/__init__.py
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
--- a/daq_system/devices/__pycache__/__init__.cpython-312.pyc
+++ /dev/null
Binary files differ
diff --git a/daq_system/devices/__pycache__/analog_input.cpython-312.pyc b/daq_system/devices/__pycache__/analog_input.cpython-312.pyc
deleted file mode 100644
index efe5752..0000000
--- a/daq_system/devices/__pycache__/analog_input.cpython-312.pyc
+++ /dev/null
Binary files differ
diff --git a/daq_system/devices/__pycache__/base_device.cpython-312.pyc b/daq_system/devices/__pycache__/base_device.cpython-312.pyc
deleted file mode 100644
index 4f08c29..0000000
--- a/daq_system/devices/__pycache__/base_device.cpython-312.pyc
+++ /dev/null
Binary files differ
diff --git a/daq_system/devices/__pycache__/device_registry.cpython-312.pyc b/daq_system/devices/__pycache__/device_registry.cpython-312.pyc
deleted file mode 100644
index 8d5ca55..0000000
--- a/daq_system/devices/__pycache__/device_registry.cpython-312.pyc
+++ /dev/null
Binary files differ
diff --git a/daq_system/devices/__pycache__/digital_io.cpython-312.pyc b/daq_system/devices/__pycache__/digital_io.cpython-312.pyc
deleted file mode 100644
index 9ca7bc7..0000000
--- a/daq_system/devices/__pycache__/digital_io.cpython-312.pyc
+++ /dev/null
Binary files differ
diff --git a/daq_system/devices/__pycache__/serial_device.cpython-312.pyc b/daq_system/devices/__pycache__/serial_device.cpython-312.pyc
deleted file mode 100644
index f4a2190..0000000
--- a/daq_system/devices/__pycache__/serial_device.cpython-312.pyc
+++ /dev/null
Binary files 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
--- a/daq_system/devices/__pycache__/temperature.cpython-312.pyc
+++ /dev/null
Binary files 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
--- a/daq_system/ui/__pycache__/alarm_panel.cpython-312.pyc
+++ /dev/null
Binary files differ
diff --git a/daq_system/ui/__pycache__/config_dialog.cpython-312.pyc b/daq_system/ui/__pycache__/config_dialog.cpython-312.pyc
deleted file mode 100644
index d62887f..0000000
--- a/daq_system/ui/__pycache__/config_dialog.cpython-312.pyc
+++ /dev/null
Binary files differ
diff --git a/daq_system/ui/__pycache__/device_panel.cpython-312.pyc b/daq_system/ui/__pycache__/device_panel.cpython-312.pyc
deleted file mode 100644
index 0616690..0000000
--- a/daq_system/ui/__pycache__/device_panel.cpython-312.pyc
+++ /dev/null
Binary files differ
diff --git a/daq_system/ui/__pycache__/main_window.cpython-312.pyc b/daq_system/ui/__pycache__/main_window.cpython-312.pyc
deleted file mode 100644
index d6af385..0000000
--- a/daq_system/ui/__pycache__/main_window.cpython-312.pyc
+++ /dev/null
Binary files differ
diff --git a/daq_system/ui/__pycache__/readout_panel.cpython-312.pyc b/daq_system/ui/__pycache__/readout_panel.cpython-312.pyc
deleted file mode 100644
index 47d94da..0000000
--- a/daq_system/ui/__pycache__/readout_panel.cpython-312.pyc
+++ /dev/null
Binary files differ
diff --git a/daq_system/ui/__pycache__/strip_chart.cpython-312.pyc b/daq_system/ui/__pycache__/strip_chart.cpython-312.pyc
deleted file mode 100644
index 95dc075..0000000
--- a/daq_system/ui/__pycache__/strip_chart.cpython-312.pyc
+++ /dev/null
Binary files 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)