diff options
48 files changed, 7043 insertions, 741 deletions
diff --git a/api_layers/__pycache__/__init__.cpython-314.pyc b/api_layers/__pycache__/__init__.cpython-314.pyc Binary files differindex 88ad51b..fe19862 100644 --- a/api_layers/__pycache__/__init__.cpython-314.pyc +++ b/api_layers/__pycache__/__init__.cpython-314.pyc diff --git a/api_layers/__pycache__/arduino_layer.cpython-314.pyc b/api_layers/__pycache__/arduino_layer.cpython-314.pyc Binary files differindex 186b224..f01edd3 100644 --- a/api_layers/__pycache__/arduino_layer.cpython-314.pyc +++ b/api_layers/__pycache__/arduino_layer.cpython-314.pyc diff --git a/api_layers/__pycache__/nidaqmx_layer.cpython-314.pyc b/api_layers/__pycache__/nidaqmx_layer.cpython-314.pyc Binary files differindex 92f0ff3..084fe36 100644 --- a/api_layers/__pycache__/nidaqmx_layer.cpython-314.pyc +++ b/api_layers/__pycache__/nidaqmx_layer.cpython-314.pyc diff --git a/api_layers/arduino_layer.py b/api_layers/arduino_layer.py index dea2783..f2b8241 100644 --- a/api_layers/arduino_layer.py +++ b/api_layers/arduino_layer.py @@ -3,81 +3,62 @@ 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() +Protocol: Arduino sends newline-terminated lines, e.g.: + "A0:1.23,A1:4.56\n" key:value pairs (default) + "1.23,4.56\n" plain CSV + {"A0":1.23,"A1":4.56} JSON + +connect() is non-blocking — serial open is done, then a background +thread reads continuously. The 2-second Arduino-reset wait is done +inside the thread so the UI never freezes. + +Swap protocol by subclassing and overriding _parse_line(). """ 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 + 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, + port: str = "COM3", + baud: int = 115200, + timeout: float = 1.0, + 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 + 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 # stored exactly as given — no override + + 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 + self._last_error: str = "" # surfaced to UI for diagnosis - # Sim waveform params self._sim_params = { pin: { "freq": 0.1 + i * 0.13, @@ -89,82 +70,141 @@ class ArduinoLayer: for i, pin in enumerate(self.analog_pins) } - # ── Lifecycle ──────────────────────────────────────────────────────── + # ── Lifecycle ───────────────────────────────────────────────────────── def connect(self) -> bool: + """ + Open the connection. + Simulation: starts waveform thread immediately → returns True. + Hardware: opens serial port synchronously (fast), then starts + read thread which handles the Arduino reset wait. + Returns True on success, False on failure. + Check self.last_error for the reason on failure. + """ self._t0 = time.time() + self._last_error = "" + if self.simulate: self._running = True - self._thread = threading.Thread(target=self._sim_loop, daemon=True) + self._thread = threading.Thread( + target=self._sim_loop, daemon=True, name="ArduinoSim" + ) self._thread.start() return True + if not _SERIAL_AVAILABLE: - print("[ArduinoLayer] pyserial not installed.") + self._last_error = "pyserial not installed — run: pip install pyserial" + print(f"[ArduinoLayer] {self._last_error}") 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 + self._ser = serial.Serial() + self._ser.port = self.port + self._ser.baudrate = self.baud + self._ser.timeout = self.timeout + self._ser.open() # raises SerialException on failure except Exception as e: - print(f"[ArduinoLayer] connect() failed: {e}") + self._last_error = str(e) + print(f"[ArduinoLayer] connect() failed on {self.port}: {e}") + self._ser = None return False + self._running = True + self._thread = threading.Thread( + target=self._read_loop, daemon=True, name=f"Arduino-{self.port}" + ) + self._thread.start() + return True + def disconnect(self) -> None: self._running = False if self._thread: self._thread.join(timeout=2.0) + self._thread = None if self._ser: try: self._ser.close() except Exception: pass - self._ser = None + self._ser = None + with self._lock: + self._cache.clear() - # ── Read / Write ──────────────────────────────────────────────────── + @property + def is_connected(self) -> bool: + if self.simulate: + return self._running + return self._ser is not None and self._ser.is_open + + @property + def last_error(self) -> str: + return self._last_error + + # ── 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()) + self._ser.write(f"W:{pin}:{int(bool(value))}\n".encode()) return True except Exception as e: print(f"[ArduinoLayer] write() failed: {e}") return False - # ── Background threads ─────────────────────────────────────────────── + # ── Background threads ──────────────────────────────────────────────── def _read_loop(self): - """Background thread: reads lines from serial port.""" - while self._running and self._ser and self._ser.is_open: + """ + Hardware read thread. + Waits 2 s for Arduino reset, then reads lines continuously. + All errors are caught so the thread never crashes silently. + """ + # Wait for Arduino to reset after serial open + deadline = time.time() + 2.5 + while time.time() < deadline and self._running: + time.sleep(0.05) + + if not self._running: + return + + # Flush any garbage from reset + try: + self._ser.reset_input_buffer() + except Exception: + pass + + consecutive_errors = 0 + while self._running: try: - line = self._ser.readline().decode("utf-8", errors="replace").strip() - if line: - parsed = self._parse_line(line) + if not self._ser or not self._ser.is_open: + break + raw = self._ser.readline() + if not raw: + continue + line = raw.decode("utf-8", errors="replace").strip() + if not line: + continue + parsed = self._parse_line(line) + if parsed: with self._lock: self._cache.update(parsed) - except Exception: - time.sleep(0.05) + consecutive_errors = 0 + except Exception as e: + consecutive_errors += 1 + if consecutive_errors <= 3: + print(f"[ArduinoLayer] read error: {e}") + if consecutive_errors > 20: + print(f"[ArduinoLayer] too many errors, stopping read loop") + break + time.sleep(0.1) def _sim_loop(self): - """Background thread: generates simulated waveforms.""" while self._running: t = time.time() - self._t0 update = {} @@ -172,124 +212,75 @@ class ArduinoLayer: 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 ───────────────────────────────────────────────── + # ── Protocol ───────────────────────────────────────────────────────── 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. + Parse common Arduino output formats: + "A0:1.23,A1:4.56" → key:value pairs + "1.23,4.56" → positional CSV mapped to analog_pins + {"A0":1.23} → JSON """ + line = line.strip() result: Dict[str, float] = {} - # Key:value pairs + + # JSON + if line.startswith("{"): + try: + import json + d = json.loads(line) + return {k: float(v) for k, v in d.items()} + except Exception: + return {} + + # Key:value CSV for token in line.split(","): token = token.strip() + if not token: + continue if ":" in token: parts = token.split(":", 1) try: result[parts[0].strip()] = float(parts[1].strip()) - except ValueError: + except (ValueError, IndexError): pass else: - # plain CSV fallback - try: - idx = len(result) - if idx < len(self.analog_pins): + idx = len(result) + if idx < len(self.analog_pins): + try: result[self.analog_pins[idx]] = float(token) - except ValueError: - pass + except ValueError: + pass return result - def _build_write_cmd(self, pin: str, value: int) -> str: - return f"W:{pin}:{int(bool(value))}\n" - - # ── Utilities ──────────────────────────────────────────────────────── + # ── Utilities ───────────────────────────────────────────────────────── @staticmethod - def list_ports() -> List[str]: - """Return available serial port names.""" + def list_ports() -> List[Tuple[str, str]]: + """ + Return list of (device, description) for all detected serial ports. + Returns [] if pyserial is not installed. + """ if not _SERIAL_AVAILABLE: return [] - return [p.device for p in serial.tools.list_ports.comports()] + try: + return [ + (p.device, p.description or "") + for p in serial.tools.list_ports.comports() + ] + except Exception as e: + print(f"[ArduinoLayer] list_ports() error: {e}") + return [] - @property - def is_simulated(self) -> bool: - return self.simulate + @staticmethod + def is_pyserial_available() -> bool: + return _SERIAL_AVAILABLE 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/core/__pycache__/__init__.cpython-314.pyc b/core/__pycache__/__init__.cpython-314.pyc Binary files differindex 2a7cb7e..e90d2a9 100644 --- a/core/__pycache__/__init__.cpython-314.pyc +++ b/core/__pycache__/__init__.cpython-314.pyc diff --git a/core/__pycache__/acquisition.cpython-314.pyc b/core/__pycache__/acquisition.cpython-314.pyc Binary files differindex d84b66b..c41e1a5 100644 --- a/core/__pycache__/acquisition.cpython-314.pyc +++ b/core/__pycache__/acquisition.cpython-314.pyc diff --git a/core/__pycache__/signal_processor.cpython-314.pyc b/core/__pycache__/signal_processor.cpython-314.pyc Binary files differnew file mode 100644 index 0000000..dc61203 --- /dev/null +++ b/core/__pycache__/signal_processor.cpython-314.pyc diff --git a/core/acquisition.py b/core/acquisition.py index 640e6d5..afccb56 100644 --- a/core/acquisition.py +++ b/core/acquisition.py @@ -3,7 +3,7 @@ core/acquisition.py Background acquisition engine. Polls all connected devices, buffers data, fires Qt signals, -writes CSV logs, and checks alarm thresholds. +and writes CSV logs. """ import csv @@ -18,7 +18,7 @@ from PyQt6.QtCore import QObject, pyqtSignal from devices.base_device import BaseDevice, DeviceStatus -MAX_BUFFER = 20_000 # samples per channel +MAX_BUFFER = 20_000 class ChannelBuffer: @@ -33,7 +33,6 @@ class ChannelBuffer: 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 @@ -63,35 +62,29 @@ class AcquisitionEngine(QObject): 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) + new_data = pyqtSignal(str, str, float, float) + 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] = {} + self._devices: List[BaseDevice] = [] + self._buffers: Dict[str, Dict[str, ChannelBuffer]] = {} + self._running = False + self._thread: Optional[threading.Thread] = None + self._t0 = 0.0 + + self._logging = False + self._log_path = "" + self._csv_file = None + self._csv_writer = None # ── Device management ──────────────────────────────────────────────── @@ -128,10 +121,11 @@ class AcquisitionEngine(QObject): # ── 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" + if not filepath or filepath.endswith(os.sep) or filepath.endswith("/"): + dir_ = filepath if filepath else "logs" + os.makedirs(dir_, exist_ok=True) + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + filepath = os.path.join(dir_, f"daq_{ts}.csv") self._log_path = filepath self._csv_file = open(filepath, "w", newline="") headers = ["elapsed_s"] @@ -163,9 +157,9 @@ class AcquisitionEngine(QObject): def _loop(self): while self._running: - t_start = time.time() - elapsed = t_start - self._t0 - log_row = [f"{elapsed:.4f}"] + 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) @@ -180,15 +174,11 @@ class AcquisitionEngine(QObject): 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: @@ -196,30 +186,7 @@ class AcquisitionEngine(QObject): except Exception: pass - # Sleep remainder of interval - dt = time.time() - t_start + 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/core/signal_processor.py b/core/signal_processor.py new file mode 100644 index 0000000..4161d4d --- /dev/null +++ b/core/signal_processor.py @@ -0,0 +1,464 @@ +""" +core/signal_processor.py + +Signal processing pipeline engine. + +Provides two capabilities: + +1. FILTERS — applied to a raw channel buffer before plotting: + LowPass, HighPass, MovingAverage, Median, Derivative, Integral, Scale+Offset + +2. DERIVED CHANNELS — virtual channels computed from one or more physical + channels. Each derived channel runs a user-defined function every time + new data arrives. Built-ins: velocity/acceleration from displacement, + power from voltage+current, RMS, etc. Custom: arbitrary Python snippet. + +Architecture +------------ + SignalProcessor sits between AcquisitionEngine and StripChartWidget. + engine.new_data → SignalProcessor.process(dev, ch, t, val) + → emits processed_data(virtual_or_real_id, ch_id, t, val) + +The processor maintains its own ring buffers for derived channels so the +strip chart can query history just like physical channels. +""" + +from __future__ import annotations + +import math +import threading +import traceback +from collections import deque +from dataclasses import dataclass, field +from typing import Callable, Dict, List, Optional, Tuple, Any + +from PyQt6.QtCore import QObject, pyqtSignal + +# ── Constants ────────────────────────────────────────────────────────────── + +MAX_BUF = 20_000 + + +# ══════════════════════════════════════════════════════════════════════════════ +# Filter definitions +# ══════════════════════════════════════════════════════════════════════════════ + +class FilterBase: + """All filters implement __call__(value: float) -> float.""" + name: str = "identity" + params: dict = {} + + def __call__(self, value: float) -> float: + return value + + def reset(self): pass + + def to_dict(self) -> dict: + return {"type": self.name, **self.params} + + +class MovingAverageFilter(FilterBase): + name = "moving_average" + def __init__(self, window: int = 10): + self.params = {"window": window} + self._buf = deque(maxlen=window) + + def __call__(self, v: float) -> float: + self._buf.append(v) + return sum(self._buf) / len(self._buf) + + def reset(self): self._buf.clear() + + +class MedianFilter(FilterBase): + name = "median" + def __init__(self, window: int = 5): + self.params = {"window": window} + self._buf = deque(maxlen=window) + + def __call__(self, v: float) -> float: + self._buf.append(v) + s = sorted(self._buf) + n = len(s) + return s[n // 2] if n % 2 else (s[n//2 - 1] + s[n//2]) / 2 + + def reset(self): self._buf.clear() + + +class LowPassFilter(FilterBase): + """Exponential moving average (single-pole IIR low-pass).""" + name = "low_pass" + def __init__(self, alpha: float = 0.1): + """alpha=0.0 → no change, 1.0 → unfiltered.""" + self.params = {"alpha": alpha} + self._prev = None + + def __call__(self, v: float) -> float: + if self._prev is None: + self._prev = v + self._prev = self._prev + self.params["alpha"] * (v - self._prev) + return self._prev + + def reset(self): self._prev = None + + +class HighPassFilter(FilterBase): + """Simple single-pole IIR high-pass (compliment of low-pass).""" + name = "high_pass" + def __init__(self, alpha: float = 0.9): + self.params = {"alpha": alpha} + self._prev_v = None + self._prev_y = 0.0 + + def __call__(self, v: float) -> float: + if self._prev_v is None: + self._prev_v = v + y = self.params["alpha"] * (self._prev_y + v - self._prev_v) + self._prev_y = y + self._prev_v = v + return y + + def reset(self): self._prev_v = None; self._prev_y = 0.0 + + +class ScaleOffsetFilter(FilterBase): + """y = scale * x + offset (unit conversion, calibration).""" + name = "scale_offset" + def __init__(self, scale: float = 1.0, offset: float = 0.0): + self.params = {"scale": scale, "offset": offset} + + def __call__(self, v: float) -> float: + return self.params["scale"] * v + self.params["offset"] + + +class DerivativeFilter(FilterBase): + """Numerical first derivative dy/dt.""" + name = "derivative" + def __init__(self): self.params = {}; self._prev_v = None; self._prev_t = None + + def process_with_t(self, v: float, t: float) -> float: + if self._prev_t is None or t == self._prev_t: + self._prev_v = v; self._prev_t = t; return 0.0 + dy = (v - self._prev_v) / (t - self._prev_t) + self._prev_v = v; self._prev_t = t + return dy + + def __call__(self, v: float) -> float: + return 0.0 # use process_with_t for real output + + def reset(self): self._prev_v = None; self._prev_t = None + + +class IntegralFilter(FilterBase): + """Numerical integration (trapezoidal rule).""" + name = "integral" + def __init__(self): self.params = {}; self._sum = 0.0; self._prev_v = None; self._prev_t = None + + def process_with_t(self, v: float, t: float) -> float: + if self._prev_t is not None and t != self._prev_t: + self._sum += 0.5 * (v + self._prev_v) * (t - self._prev_t) + self._prev_v = v; self._prev_t = t + return self._sum + + def __call__(self, v: float) -> float: + return self._sum + + def reset(self): self._sum = 0.0; self._prev_v = None; self._prev_t = None + + +FILTER_CLASSES = { + "moving_average": MovingAverageFilter, + "median": MedianFilter, + "low_pass": LowPassFilter, + "high_pass": HighPassFilter, + "scale_offset": ScaleOffsetFilter, + "derivative": DerivativeFilter, + "integral": IntegralFilter, +} + + +def filter_from_dict(d: dict) -> FilterBase: + cls = FILTER_CLASSES.get(d.get("type", "")) + if cls is None: + return FilterBase() + params = {k: v for k, v in d.items() if k != "type"} + return cls(**params) + + +# ══════════════════════════════════════════════════════════════════════════════ +# Derived channel definitions +# ══════════════════════════════════════════════════════════════════════════════ + +@dataclass +class DerivedChannel: + """ + A virtual channel computed from one or more physical channels. + + kind options: + "velocity" — derivative of a displacement source + "acceleration" — second derivative of a displacement source + "power" — voltage_source * current_source + "rms" — rolling RMS of a source (window samples) + "expression" — arbitrary Python expression string + "function" — multi-line Python function body (def compute(...)) + "custom_script" — full Python script, must define compute(inputs, t) + """ + channel_id: str # virtual ID, e.g. "vel_0" + name: str # display name + unit: str = "" + color: str = "#f72585" + kind: str = "expression" # see above + # Source channel references [("dev_id", "ch_id"), ...] + sources: List[Tuple[str, str]] = field(default_factory=list) + # For built-in kinds + params: Dict[str, Any] = field(default_factory=dict) + # For expression / function / custom_script + expression: str = "" # single-line: "x[0] * 2" + script: str = "" # multi-line function body + enabled: bool = True + # Runtime: compiled callable (not serialised) + _fn: Optional[Callable] = field(default=None, repr=False, compare=False) + + def compile(self) -> Optional[str]: + """ + Compile expression/script into self._fn. + Returns None on success, or error string on failure. + """ + try: + if self.kind == "expression": + # Single-line: inputs are x (list of latest values), t (time) + code = compile(f"__result__ = {self.expression}", "<expr>", "exec") + def _expr_fn(inputs, t, _code=code): + ns = {"x": inputs, "t": t, "math": math} + exec(_code, ns) + return float(ns["__result__"]) + self._fn = _expr_fn + + elif self.kind in ("function", "custom_script"): + # User provides a def compute(x, t): ... body + # We wrap it in a module namespace + src = self.script + if not src.strip().startswith("def compute"): + src = "def compute(x, t):\n" + "\n".join( + " " + ln for ln in src.splitlines() + ) + ns: dict = {"math": math} + exec(compile(src, "<script>", "exec"), ns) + fn = ns["compute"] + self._fn = lambda inputs, t, _f=fn: float(_f(inputs, t)) + + else: + # Built-in kinds handled in SignalProcessor._compute_derived + self._fn = None + + return None + except Exception as e: + self._fn = None + return str(e) + + +# ══════════════════════════════════════════════════════════════════════════════ +# ChannelPipeline — per-channel filter stack +# ══════════════════════════════════════════════════════════════════════════════ + +@dataclass +class ChannelPipeline: + device_id: str + channel_id: str + filters: List[FilterBase] = field(default_factory=list) + enabled: bool = True + + def process(self, value: float, timestamp: float) -> float: + if not self.enabled: + return value + v = value + for f in self.filters: + if isinstance(f, (DerivativeFilter, IntegralFilter)): + v = f.process_with_t(v, timestamp) + else: + v = f(v) + return v + + def reset(self): + for f in self.filters: + f.reset() + + +# ══════════════════════════════════════════════════════════════════════════════ +# SignalProcessor +# ══════════════════════════════════════════════════════════════════════════════ + +class SignalProcessor(QObject): + """ + Sits between AcquisitionEngine and StripChartWidget. + + Applies filter pipelines to raw channel data, then evaluates all + derived channels and emits processed_data for everything. + + Connect: engine.new_data → processor.on_raw_data + Connect: processor.processed_data → chart.on_new_data + """ + + processed_data = pyqtSignal(str, str, float, float) + # device_id, channel_id, timestamp, value + # For derived channels: device_id = "derived", channel_id = derived.channel_id + + derived_error = pyqtSignal(str, str) # channel_id, error_message + + def __init__(self): + super().__init__() + self._pipelines: Dict[Tuple[str, str], ChannelPipeline] = {} + self._derived: List[DerivedChannel] = [] + self._lock = threading.Lock() + + # Latest raw values cache for derived evaluation + # (dev_id, ch_id) -> (timestamp, value) + self._latest: Dict[Tuple[str, str], Tuple[float, float]] = {} + + # Ring buffers for derived channels (so strip chart can query history) + self._derived_bufs: Dict[str, Tuple[deque, deque]] = {} + + # ── Pipeline management ─────────────────────────────────────────────── + + def set_pipeline(self, pipeline: ChannelPipeline): + with self._lock: + self._pipelines[(pipeline.device_id, pipeline.channel_id)] = pipeline + + def remove_pipeline(self, device_id: str, channel_id: str): + with self._lock: + self._pipelines.pop((device_id, channel_id), None) + + def get_pipeline(self, device_id: str, channel_id: str) -> Optional[ChannelPipeline]: + return self._pipelines.get((device_id, channel_id)) + + # ── Derived channel management ──────────────────────────────────────── + + def add_derived(self, dc: DerivedChannel) -> Optional[str]: + """Add a derived channel. Returns compile error string or None.""" + err = dc.compile() + if err: + return err + with self._lock: + self._derived = [d for d in self._derived if d.channel_id != dc.channel_id] + self._derived.append(dc) + self._derived_bufs[dc.channel_id] = (deque(maxlen=MAX_BUF), deque(maxlen=MAX_BUF)) + return None + + def remove_derived(self, channel_id: str): + with self._lock: + self._derived = [d for d in self._derived if d.channel_id != channel_id] + self._derived_bufs.pop(channel_id, None) + + def get_derived(self) -> List[DerivedChannel]: + return list(self._derived) + + def get_derived_buffer(self, channel_id: str): + """Returns (times_deque, values_deque) or None.""" + return self._derived_bufs.get(channel_id) + + def all_virtual_channel_ids(self) -> List[str]: + return list(self._derived_bufs.keys()) + + # ── Main data path ──────────────────────────────────────────────────── + + def on_raw_data(self, device_id: str, channel_id: str, + timestamp: float, value: float): + """Slot: receive raw data, apply filters, emit processed, update derived.""" + key = (device_id, channel_id) + + # Apply filter pipeline + with self._lock: + pipeline = self._pipelines.get(key) + processed = pipeline.process(value, timestamp) if pipeline else value + + # Cache latest processed value + with self._lock: + self._latest[key] = (timestamp, processed) + + # Emit processed physical channel + self.processed_data.emit(device_id, channel_id, timestamp, processed) + + # Evaluate all derived channels whose sources include this channel + self._evaluate_derived(timestamp) + + def _evaluate_derived(self, timestamp: float): + with self._lock: + derived = list(self._derived) + latest = dict(self._latest) + + for dc in derived: + if not dc.enabled: + continue + # Check all sources have recent data + inputs = [] + for src in dc.sources: + entry = latest.get(src) + if entry is None: + break + inputs.append(entry[1]) # value only + else: + # All sources present + try: + result = self._compute_derived(dc, inputs, timestamp, latest) + if result is not None: + bufs = self._derived_bufs.get(dc.channel_id) + if bufs: + bufs[0].append(timestamp) + bufs[1].append(result) + self.processed_data.emit("derived", dc.channel_id, + timestamp, result) + except Exception as e: + self.derived_error.emit(dc.channel_id, + traceback.format_exc(limit=3)) + + def _compute_derived(self, dc: DerivedChannel, inputs: List[float], + t: float, latest: dict) -> Optional[float]: + if dc.kind in ("expression", "function", "custom_script"): + if dc._fn is None: + return None + return dc._fn(inputs, t) + + elif dc.kind == "velocity": + # derivative of source[0] + src = dc.sources[0] if dc.sources else None + if src is None: return None + state = dc.params.setdefault("_state", {}) + prev_t = state.get("t"); prev_v = state.get("v") + state["t"] = t; state["v"] = inputs[0] + if prev_t is None or t == prev_t: return 0.0 + return (inputs[0] - prev_v) / (t - prev_t) + + elif dc.kind == "acceleration": + # second derivative — derivative of velocity + src = dc.sources[0] if dc.sources else None + if src is None: return None + state = dc.params.setdefault("_state", {}) + # First get velocity + prev_t = state.get("t"); prev_v = state.get("v") + cur_vel = 0.0 + if prev_t is not None and t != prev_t: + cur_vel = (inputs[0] - prev_v) / (t - prev_t) + prev_vel = state.get("vel", 0.0) + state["t"] = t; state["v"] = inputs[0]; state["vel"] = cur_vel + if prev_t is None or t == prev_t: return 0.0 + return (cur_vel - prev_vel) / (t - prev_t) + + elif dc.kind == "power": + # V * I + if len(inputs) < 2: return None + return inputs[0] * inputs[1] + + elif dc.kind == "rms": + window = dc.params.get("window", 20) + buf = dc.params.setdefault("_buf", deque(maxlen=window)) + buf.append(inputs[0]) + return math.sqrt(sum(x*x for x in buf) / len(buf)) + + elif dc.kind == "difference": + if len(inputs) < 2: return None + return inputs[0] - inputs[1] + + elif dc.kind == "sum": + return sum(inputs) + + return None diff --git a/devices/__pycache__/__init__.cpython-314.pyc b/devices/__pycache__/__init__.cpython-314.pyc Binary files differindex 9ab29a6..bbe1fd2 100644 --- a/devices/__pycache__/__init__.cpython-314.pyc +++ b/devices/__pycache__/__init__.cpython-314.pyc diff --git a/devices/__pycache__/analog_input.cpython-314.pyc b/devices/__pycache__/analog_input.cpython-314.pyc Binary files differindex 4c08631..27ce47c 100644 --- a/devices/__pycache__/analog_input.cpython-314.pyc +++ b/devices/__pycache__/analog_input.cpython-314.pyc diff --git a/devices/__pycache__/base_device.cpython-314.pyc b/devices/__pycache__/base_device.cpython-314.pyc Binary files differindex ade2b20..358f1ed 100644 --- a/devices/__pycache__/base_device.cpython-314.pyc +++ b/devices/__pycache__/base_device.cpython-314.pyc diff --git a/devices/__pycache__/device_registry.cpython-314.pyc b/devices/__pycache__/device_registry.cpython-314.pyc Binary files differindex 608c8ee..d4adce0 100644 --- a/devices/__pycache__/device_registry.cpython-314.pyc +++ b/devices/__pycache__/device_registry.cpython-314.pyc diff --git a/devices/__pycache__/digital_io.cpython-314.pyc b/devices/__pycache__/digital_io.cpython-314.pyc Binary files differindex b2bb27f..2fd4200 100644 --- a/devices/__pycache__/digital_io.cpython-314.pyc +++ b/devices/__pycache__/digital_io.cpython-314.pyc diff --git a/devices/__pycache__/serial_device.cpython-314.pyc b/devices/__pycache__/serial_device.cpython-314.pyc Binary files differindex 0674606..9ef9fca 100644 --- a/devices/__pycache__/serial_device.cpython-314.pyc +++ b/devices/__pycache__/serial_device.cpython-314.pyc diff --git a/devices/analog_input.py b/devices/analog_input.py index 5bb5d28..6183f43 100644 --- a/devices/analog_input.py +++ b/devices/analog_input.py @@ -1,30 +1,23 @@ """ 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. +Analog Input device. Backends: NI-DAQmx or Arduino. """ from typing import Any, Dict, List from PyQt6.QtWidgets import ( - QWidget, QVBoxLayout, QFormLayout, QGroupBox, - QComboBox, QDoubleSpinBox, QSpinBox, QCheckBox, - QLineEdit, QLabel, QPushButton, QHBoxLayout, + QWidget, QVBoxLayout, QHBoxLayout, QFormLayout, QGroupBox, + QComboBox, QDoubleSpinBox, QCheckBox, QLineEdit, QLabel, + QPushButton, QListWidget, QListWidgetItem, QFrame, QTextEdit, ) -from PyQt6.QtCore import Qt +from PyQt6.QtCore import Qt, QThread, pyqtSignal +from PyQt6.QtGui import QFont 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", @@ -34,49 +27,38 @@ _COLORS = [ 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, + device_id: str = "ai_0", + num_channels: int = 4, + simulate: bool = True, + backend: str = "nidaqmx", + ni_device: str = "Dev1", + ni_min_v: float = -10.0, + ni_max_v: float = 10.0, + ard_port: str = "COM3", + ard_baud: int = 115200, ): - self.backend = backend - self.simulate = simulate + 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)], - ) + ChannelConfig(channel_id=p, name=p, unit="V", + min_value=0.0, max_value=5.0, + 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)], - ) + ChannelConfig(channel_id=p, name=p.upper(), unit="V", + min_value=ni_min_v, max_value=ni_max_v, + color=_COLORS[i % len(_COLORS)]) for i, p in enumerate(ni_pins) ] @@ -91,155 +73,239 @@ class AnalogInputDevice(BaseDevice): ) super().__init__(info) - # Instantiate the backend layer + 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 + self._last_error = "" + + self._layer = self._make_layer( + backend, simulate, ni_device, ni_min_v, ni_max_v, + ard_port, ard_baud, + [ch.channel_id for ch in channels], + ) + + def _make_layer(self, backend, simulate, + ni_device, ni_min_v, ni_max_v, + ard_port, ard_baud, channel_ids): if backend == "arduino": - self._layer = ArduinoLayer( + return ArduinoLayer( port=ard_port, baud=ard_baud, - analog_pins=[ch.channel_id for ch in channels], + analog_pins=channel_ids, simulate=simulate, ) else: - self._layer = NidaqmxLayer( + return NidaqmxLayer( device_name=ni_device, - channels=[ch.channel_id for ch in channels], + channels=channel_ids, 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 ──────────────────────────────────────────── + # ── BaseDevice ────────────────────────────────────────────────────────── 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 - ) + self._last_error = "" + + # ArduinoLayer uses connect(); NidaqmxLayer uses start() + if hasattr(self._layer, "connect"): + ok = self._layer.connect() + else: + ok = self._layer.start() + + # Surface the underlying error message if available + if not ok and hasattr(self._layer, "last_error"): + self._last_error = self._layer.last_error + + if self.simulate: + self.status = DeviceStatus.SIMULATED + else: + self.status = 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() + try: + if hasattr(self._layer, "disconnect"): + self._layer.disconnect() + elif hasattr(self._layer, "stop"): + self._layer.stop() + except Exception: + pass 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 + return False 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.""" + def switch_backend(self, backend: str, simulate: bool, + ni_device: str, min_val: float, max_val: float, + port: str, baud: int) -> None: 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, - ) + + self.backend = backend + self.simulate = simulate + self._ni_device = ni_device + self._ni_min_v = min_val + self._ni_max_v = max_val + self._ard_port = port + self._ard_baud = baud + + self._layer = self._make_layer( + backend, simulate, ni_device, min_val, max_val, + port, baud, + [ch.channel_id for ch in self.info.channels], + ) + if was_running: self.connect() -# ── Config Widget ──────────────────────────────────────────────────────────── +# ── Port scanner thread ─────────────────────────────────────────────────────── + +class _PortScanThread(QThread): + done = pyqtSignal(list) + + def run(self): + ports = ArduinoLayer.list_ports() # returns [(device, desc), ...] + self.done.emit(ports) + + +# ── Config widget ───────────────────────────────────────────────────────────── class AnalogInputConfigWidget(QWidget): def __init__(self, device: AnalogInputDevice): super().__init__() - self.device = device + self.device = device + self._scanner = None self._build() def _build(self): root = QVBoxLayout(self) - root.setContentsMargins(0, 0, 0, 0) + root.setContentsMargins(10, 14, 10, 10) + root.setSpacing(12) + self.setMinimumWidth(460) - # ── Backend selector ────────────────────────────────────────── - be_grp = QGroupBox("API Backend") + # ── Backend / Simulate ─────────────────────────────────────────── + be_grp = QGroupBox("Backend Selection") be_form = QFormLayout(be_grp) + be_form.setContentsMargins(10, 16, 10, 10) 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 = QCheckBox("Simulation Mode (no hardware)") self.sim_check.setChecked(self.device.simulate) be_form.addRow(self.sim_check) - root.addWidget(be_grp) - # ── NI settings ─────────────────────────────────────────────── + # ── NI-DAQmx ──────────────────────────────────────────────────── self.ni_grp = QGroupBox("NI-DAQmx Settings") ni_form = QFormLayout(self.ni_grp) + ni_form.setContentsMargins(10, 16, 10, 10) self.ni_dev_edit = QLineEdit(self.device._ni_device) - ni_form.addRow("Device:", self.ni_dev_edit) + ni_form.addRow("NI 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.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) + ni_form.addRow("Min Voltage:", 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.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) + ni_form.addRow("Max Voltage:", self.ni_max_spin) - # Detect button detect_btn = QPushButton("Detect NI Devices") + detect_btn.setObjectName("configButton") detect_btn.clicked.connect(self._detect_ni) ni_form.addRow(detect_btn) self.ni_detect_lbl = QLabel("") + self.ni_detect_lbl.setObjectName("traceSource") 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) + # ── Arduino ────────────────────────────────────────────────────── + self.ard_grp = QGroupBox("Arduino Serial Settings") + ard_lay = QVBoxLayout(self.ard_grp) + ard_lay.setContentsMargins(10, 16, 10, 10) + ard_lay.setSpacing(8) + ard_form = QFormLayout() self.ard_port_edit = QLineEdit(self.device._ard_port) + self.ard_port_edit.setPlaceholderText("e.g. /dev/ttyUSB0 or COM3") 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.addItems(["9600", "19200", "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) + ard_lay.addLayout(ard_form) + + # Scan row + scan_row = QHBoxLayout() + self._scan_btn = QPushButton("🔍 Scan Ports") + self._scan_btn.setObjectName("addTraceBtn") + self._scan_btn.clicked.connect(self._scan_ports) + self._scan_lbl = QLabel("") + self._scan_lbl.setObjectName("traceSource") + scan_row.addWidget(self._scan_btn) + scan_row.addWidget(self._scan_lbl, 1) + ard_lay.addLayout(scan_row) + + # Port list + self._port_list = QListWidget() + self._port_list.setObjectName("portList") + self._port_list.setMaximumHeight(90) + self._port_list.itemClicked.connect(self._on_port_selected) + ard_lay.addWidget(self._port_list) + + hint = QLabel("↑ Click a port to fill the field above") + hint.setObjectName("traceSource") + ard_lay.addWidget(hint) + + # pyserial availability notice + if not ArduinoLayer.is_pyserial_available(): + warn = QLabel("⚠ pyserial not installed — run: pip install pyserial") + warn.setStyleSheet("color:#f59e0b; font-weight:600;") + ard_lay.addWidget(warn) root.addWidget(self.ard_grp) - # ── Apply button ────────────────────────────────────────────── + # ── Diagnostics ─────────────────────────────────────────────────── + self.diag_grp = QGroupBox("Connection Diagnostics") + diag_lay = QVBoxLayout(self.diag_grp) + diag_lay.setContentsMargins(10, 16, 10, 10) + + self._diag_box = QTextEdit() + self._diag_box.setObjectName("codeEditor") + self._diag_box.setReadOnly(True) + self._diag_box.setMaximumHeight(90) + self._diag_box.setPlaceholderText("Connection log will appear here…") + diag_lay.addWidget(self._diag_box) + + # Show any existing error + if self.device._last_error: + self._diag_box.setPlainText(f"Last error:\n{self.device._last_error}") + + root.addWidget(self.diag_grp) + + # ── Apply ───────────────────────────────────────────────────────── apply_btn = QPushButton("Apply & Reconnect") apply_btn.setObjectName("applyButton") apply_btn.clicked.connect(self._apply) @@ -248,29 +314,96 @@ class AnalogInputConfigWidget(QWidget): self._update_visibility() self.backend_cb.currentTextChanged.connect(self._update_visibility) + self._refresh_diag() + + # ── Helpers ────────────────────────────────────────────────────────────── def _update_visibility(self): be = self.backend_cb.currentText() self.ni_grp.setVisible(be == "nidaqmx") self.ard_grp.setVisible(be == "arduino") + def _refresh_diag(self): + lines = [] + lines.append(f"Backend: {self.device.backend}") + lines.append(f"Simulate: {self.device.simulate}") + lines.append(f"Status: {self.device.status.value}") + if self.device.backend == "arduino": + lines.append(f"Port: {self.device._ard_port}") + lines.append(f"Baud: {self.device._ard_baud}") + lines.append(f"pyserial: {'available' if ArduinoLayer.is_pyserial_available() else 'NOT INSTALLED'}") + if self.device._last_error: + lines.append(f"\nError:\n{self.device._last_error}") + self._diag_box.setPlainText("\n".join(lines)) + 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") + self.ni_detect_lbl.setText( + ", ".join(devs) if devs else "No NI devices found (nidaqmx package required)" + ) 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") + self._scan_btn.setEnabled(False) + self._scan_lbl.setText("Scanning…") + self._port_list.clear() + self._scanner = _PortScanThread() + self._scanner.done.connect(self._on_scan_done) + self._scanner.start() + + def _on_scan_done(self, ports): + self._scan_btn.setEnabled(True) + self._port_list.clear() + + if not ArduinoLayer.is_pyserial_available(): + self._scan_lbl.setText("pyserial not installed") + item = QListWidgetItem(" Install pyserial: pip install pyserial") + item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsSelectable) + self._port_list.addItem(item) + return + + if not ports: + self._scan_lbl.setText("No ports detected") + item = QListWidgetItem( + " No serial ports found.\n" + " • Check USB cable\n" + " • On Linux: sudo usermod -aG dialout $USER then re-login\n" + " • On Arch: sudo usermod -aG uucp $USER then re-login" + ) + item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsSelectable) + self._port_list.addItem(item) + else: + self._scan_lbl.setText(f"{len(ports)} port(s) found — click to select") + for device, desc in ports: + label = f" {device}" + if desc and desc.strip() and desc.strip() != device: + label += f" — {desc}" + item = QListWidgetItem(label) + item.setData(Qt.ItemDataRole.UserRole, device) + self._port_list.addItem(item) + + def _on_port_selected(self, item: QListWidgetItem): + port = item.data(Qt.ItemDataRole.UserRole) + if port: + self.ard_port_edit.setText(port) def _apply(self): + backend = self.backend_cb.currentText() + simulate = self.sim_check.isChecked() + port = self.ard_port_edit.text().strip() + baud = int(self.ard_baud_cb.currentText()) + + self._diag_box.setPlainText( + f"Connecting...\nBackend: {backend}\nSimulate: {simulate}\n" + + (f"Port: {port}\nBaud: {baud}" if backend == "arduino" else "") + ) + self.device.switch_backend( - backend=self.backend_cb.currentText(), - simulate=self.sim_check.isChecked(), - ni_device=self.ni_dev_edit.text(), + backend=backend, + simulate=simulate, + ni_device=self.ni_dev_edit.text().strip() or "Dev1", 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()), + port=port, + baud=baud, ) + self._refresh_diag() diff --git a/devices/base_device.py b/devices/base_device.py index 922a743..32fe787 100644 --- a/devices/base_device.py +++ b/devices/base_device.py @@ -27,8 +27,6 @@ class ChannelConfig: 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) diff --git a/devices/digital_io.py b/devices/digital_io.py index 23a345f..291baea 100644 --- a/devices/digital_io.py +++ b/devices/digital_io.py @@ -222,7 +222,8 @@ class DigitalIOConfigWidget(QWidget): def _build(self): root = QVBoxLayout(self) - root.setContentsMargins(0, 0, 0, 0) + root.setContentsMargins(8, 8, 8, 8) + self.setMinimumWidth(380) be_grp = QGroupBox("Backend") be_form = QFormLayout(be_grp) diff --git a/devices/serial_device.py b/devices/serial_device.py index 6bb7bf1..a5bf08c 100644 --- a/devices/serial_device.py +++ b/devices/serial_device.py @@ -132,17 +132,25 @@ class SerialDevice(BaseDevice): class SerialConfigWidget(QWidget): def __init__(self, device: SerialDevice): super().__init__() - self.device = device + self.device = device + self._scanner = None self._build() def _build(self): + from PyQt6.QtWidgets import QListWidget, QListWidgetItem + from PyQt6.QtCore import Qt as _Qt + root = QVBoxLayout(self) - root.setContentsMargins(0, 0, 0, 0) + root.setContentsMargins(8, 8, 8, 8) + root.setSpacing(8) + self.setMinimumWidth(420) + # ── Port settings ───────────────────────────────────────────── grp = QGroupBox("Port Settings") form = QFormLayout(grp) self.port_edit = QLineEdit(self.device._port) + self.port_edit.setPlaceholderText("e.g. COM3 or /dev/ttyUSB0") form.addRow("Port:", self.port_edit) self.baud_cb = QComboBox() @@ -155,19 +163,43 @@ class SerialConfigWidget(QWidget): 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) + self.sim_cb = QComboBox() + self.sim_cb.addItems(["Simulate", "Real Hardware"]) + self.sim_cb.setCurrentIndex(0 if self.device.simulate else 1) + form.addRow("Mode:", self.sim_cb) root.addWidget(grp) + # ── Port scanner ────────────────────────────────────────────── + scan_grp = QGroupBox("Available Serial Ports") + scan_lay = QVBoxLayout(scan_grp) + scan_lay.setSpacing(4) + + scan_top = QHBoxLayout() + self._scan_btn = QPushButton("🔍 Scan Ports") + self._scan_btn.setObjectName("addTraceBtn") + self._scan_btn.clicked.connect(self._scan) + self._scan_lbl = QLabel("Click Scan to detect ports") + self._scan_lbl.setObjectName("traceSource") + scan_top.addWidget(self._scan_btn) + scan_top.addWidget(self._scan_lbl, 1) + scan_lay.addLayout(scan_top) + + self._port_list = QListWidget() + self._port_list.setObjectName("portList") + self._port_list.setMaximumHeight(110) + self._port_list.itemClicked.connect( + lambda item: self.port_edit.setText( + item.data(_Qt.ItemDataRole.UserRole) or "" + ) if item.data(_Qt.ItemDataRole.UserRole) else None + ) + hint = QLabel("↑ Click a port above to select it") + hint.setObjectName("traceSource") + scan_lay.addWidget(self._port_list) + scan_lay.addWidget(hint) + root.addWidget(scan_grp) + + # ── Apply ───────────────────────────────────────────────────── apply_btn = QPushButton("Apply & Reconnect") apply_btn.setObjectName("applyButton") apply_btn.clicked.connect(self._apply) @@ -175,13 +207,41 @@ class SerialConfigWidget(QWidget): root.addStretch() def _scan(self): - ports = ArduinoLayer.list_ports() - self.port_lbl.setText(", ".join(ports) if ports else "None found") + self._scan_btn.setEnabled(False) + self._scan_lbl.setText("Scanning…") + self._port_list.clear() + from PyQt6.QtCore import QThread, pyqtSignal as _sig + class _Scanner(QThread): + done = _sig(list) + def run(self): + self.done.emit(ArduinoLayer.list_ports()) + self._scanner = _Scanner() + self._scanner.done.connect(self._on_scan_done) + self._scanner.start() + + def _on_scan_done(self, ports): + from PyQt6.QtWidgets import QListWidgetItem + self._scan_btn.setEnabled(True) + self._port_list.clear() + if not ports: + self._scan_lbl.setText("No ports found") + item = QListWidgetItem(" No serial ports detected — check USB / permissions") + item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsSelectable) + self._port_list.addItem(item) + else: + self._scan_lbl.setText(f"{len(ports)} port(s) — click to select") + for device, desc in ports: + label = f" {device}" + if desc and desc.strip() and desc.strip() != device: + label += f" — {desc}" + item = QListWidgetItem(label) + item.setData(Qt.ItemDataRole.UserRole, device) + self._port_list.addItem(item) def _apply(self): self.device.reconfigure( - port=self.port_edit.text(), + port=self.port_edit.text().strip() or "COM3", baud=int(self.baud_cb.currentText()), fmt=self.fmt_cb.currentText(), - simulate=(self.sim_chk.currentIndex() == 0), + simulate=(self.sim_cb.currentIndex() == 0), ) @@ -6,14 +6,9 @@ Run: Requirements: pip install PyQt6 pyqtgraph numpy pyserial - -Optional (real hardware): - pip install nidaqmx # NI-DAQmx runtime must also be installed """ -import sys -import os - +import sys, os sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from PyQt6.QtWidgets import QApplication @@ -23,12 +18,11 @@ from ui.main_window import MainWindow def main(): app = QApplication(sys.argv) app.setApplicationName("LabDAQ") - app.setOrganizationName("Lab Instruments") - # Note: AA_UseHighDpiPixmaps was removed in PyQt6 — HiDPI is always on - qss_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ui", "style.qss") - with open(qss_path, "r") as f: - app.setStyleSheet(f.read()) + qss = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ui", "style_dark.qss") + if os.path.exists(qss): + with open(qss) as f: + app.setStyleSheet(f.read()) win = MainWindow() win.show() diff --git a/plot.png b/plot.png Binary files differnew file mode 100644 index 0000000..1094f27 --- /dev/null +++ b/plot.png diff --git a/ui/__pycache__/__init__.cpython-314.pyc b/ui/__pycache__/__init__.cpython-314.pyc Binary files differindex 6e83590..0a61fd6 100644 --- a/ui/__pycache__/__init__.cpython-314.pyc +++ b/ui/__pycache__/__init__.cpython-314.pyc diff --git a/ui/__pycache__/config_dialog.cpython-314.pyc b/ui/__pycache__/config_dialog.cpython-314.pyc Binary files differindex 8876af3..f48f640 100644 --- a/ui/__pycache__/config_dialog.cpython-314.pyc +++ b/ui/__pycache__/config_dialog.cpython-314.pyc diff --git a/ui/__pycache__/control_panel.cpython-314.pyc b/ui/__pycache__/control_panel.cpython-314.pyc Binary files differnew file mode 100644 index 0000000..1e9e5a0 --- /dev/null +++ b/ui/__pycache__/control_panel.cpython-314.pyc diff --git a/ui/__pycache__/main_window.cpython-314.pyc b/ui/__pycache__/main_window.cpython-314.pyc Binary files differindex 27e4279..5b1082b 100644 --- a/ui/__pycache__/main_window.cpython-314.pyc +++ b/ui/__pycache__/main_window.cpython-314.pyc diff --git a/ui/__pycache__/strip_chart.cpython-314.pyc b/ui/__pycache__/strip_chart.cpython-314.pyc Binary files differindex 4af6ff5..27eef71 100644 --- a/ui/__pycache__/strip_chart.cpython-314.pyc +++ b/ui/__pycache__/strip_chart.cpython-314.pyc diff --git a/ui/add_device_dialog.py b/ui/add_device_dialog.py index 30d0016..03ece63 100644 --- a/ui/add_device_dialog.py +++ b/ui/add_device_dialog.py @@ -1,26 +1,264 @@ """ -ui/add_device_dialog.py — Dialog to add a new device at runtime. +ui/add_device_dialog.py + +Add Device dialog — type-aware, shows the right fields for each device. + +Each device type gets its own config panel so the user sees exactly +the fields they need and nothing irrelevant. + +Serial port scanning populates a clickable list that sets the port field. """ +import os from PyQt6.QtWidgets import ( - QDialog, QVBoxLayout, QFormLayout, QHBoxLayout, - QComboBox, QLineEdit, QSpinBox, QCheckBox, - QPushButton, QLabel, QMessageBox, + QDialog, QVBoxLayout, QHBoxLayout, QFormLayout, + QComboBox, QLineEdit, QSpinBox, QDoubleSpinBox, + QCheckBox, QPushButton, QLabel, QMessageBox, + QStackedWidget, QWidget, QGroupBox, QListWidget, + QListWidgetItem, QFrame, QSizePolicy, ) +from PyQt6.QtCore import Qt, QThread, pyqtSignal +from PyQt6.QtGui import QFont 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, {}), +from devices.analog_input import AnalogInputDevice +from devices.digital_io import DigitalIODevice +from devices.serial_device import SerialDevice + + +# ── Port scanner thread ─────────────────────────────────────────────────────── + +class PortScanThread(QThread): + ports_found = pyqtSignal(list) + + def run(self): + from api_layers.arduino_layer import ArduinoLayer + self.ports_found.emit(ArduinoLayer.list_ports()) + + +# ── Per-type config panels ──────────────────────────────────────────────────── + +class AnalogInputPanel(QWidget): + """Config fields for AnalogInputDevice.""" + + def __init__(self): + super().__init__() + lay = QFormLayout(self) + lay.setContentsMargins(0, 4, 0, 4) + + # Backend + self.backend_cb = QComboBox() + self.backend_cb.addItems(["nidaqmx", "arduino"]) + lay.addRow("Backend:", self.backend_cb) + + # Channel count + self.ch_spin = QSpinBox() + self.ch_spin.setRange(1, 16) + self.ch_spin.setValue(4) + lay.addRow("Channels:", self.ch_spin) + + # Voltage range (NI) + self.ni_device_edit = QLineEdit("Dev1") + lay.addRow("NI Device:", self.ni_device_edit) + + self.min_v_spin = QDoubleSpinBox() + self.min_v_spin.setRange(-100.0, 0.0) + self.min_v_spin.setValue(-10.0) + self.min_v_spin.setSuffix(" V") + lay.addRow("Min Voltage:", self.min_v_spin) + + self.max_v_spin = QDoubleSpinBox() + self.max_v_spin.setRange(0.0, 100.0) + self.max_v_spin.setValue(10.0) + self.max_v_spin.setSuffix(" V") + lay.addRow("Max Voltage:", self.max_v_spin) + + # Arduino port (shown always, relevant for arduino backend) + self.ard_port_edit = QLineEdit("") + self.ard_port_edit.setPlaceholderText("e.g. COM3 or /dev/ttyUSB0") + lay.addRow("Arduino Port:", self.ard_port_edit) + + self.ard_baud_cb = QComboBox() + self.ard_baud_cb.addItems(["9600", "57600", "115200", "230400"]) + self.ard_baud_cb.setCurrentText("115200") + lay.addRow("Arduino Baud:", self.ard_baud_cb) + + # Simulate + self.sim_chk = QCheckBox("Simulation mode") + self.sim_chk.setChecked(True) + lay.addRow(self.sim_chk) + + def build_device(self, device_id: str) -> AnalogInputDevice: + return AnalogInputDevice( + device_id=device_id, + num_channels=self.ch_spin.value(), + simulate=self.sim_chk.isChecked(), + backend=self.backend_cb.currentText(), + ni_device=self.ni_device_edit.text().strip() or "Dev1", + ni_min_v=self.min_v_spin.value(), + ni_max_v=self.max_v_spin.value(), + ard_port=self.ard_port_edit.text().strip() or "COM3", + ard_baud=int(self.ard_baud_cb.currentText()), + ) + + +class DigitalIOPanel(QWidget): + """Config fields for DigitalIODevice.""" + + def __init__(self): + super().__init__() + lay = QFormLayout(self) + lay.setContentsMargins(0, 4, 0, 4) + + self.backend_cb = QComboBox() + self.backend_cb.addItems(["nidaqmx", "arduino"]) + lay.addRow("Backend:", self.backend_cb) + + self.in_spin = QSpinBox() + self.in_spin.setRange(0, 32) + self.in_spin.setValue(4) + lay.addRow("Digital Inputs:", self.in_spin) + + self.out_spin = QSpinBox() + self.out_spin.setRange(0, 32) + self.out_spin.setValue(4) + lay.addRow("Digital Outputs:", self.out_spin) + + self.ni_device_edit = QLineEdit("Dev1") + lay.addRow("NI Device:", self.ni_device_edit) + + self.ard_port_edit = QLineEdit("") + self.ard_port_edit.setPlaceholderText("e.g. COM3 or /dev/ttyUSB0") + lay.addRow("Arduino Port:", self.ard_port_edit) + + self.sim_chk = QCheckBox("Simulation mode") + self.sim_chk.setChecked(True) + lay.addRow(self.sim_chk) + + def build_device(self, device_id: str) -> DigitalIODevice: + return DigitalIODevice( + device_id=device_id, + num_inputs=self.in_spin.value(), + num_outputs=self.out_spin.value(), + simulate=self.sim_chk.isChecked(), + backend=self.backend_cb.currentText(), + ni_device=self.ni_device_edit.text().strip() or "Dev1", + ard_port=self.ard_port_edit.text().strip() or "COM3", + ) + + +class SerialPanel(QWidget): + """Config fields for SerialDevice with live port scanner.""" + + def __init__(self): + super().__init__() + lay = QVBoxLayout(self) + lay.setContentsMargins(0, 4, 0, 4) + lay.setSpacing(8) + + form = QFormLayout() + + self.port_edit = QLineEdit("") + self.port_edit.setPlaceholderText("e.g. COM3 or /dev/ttyUSB0") + 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("115200") + form.addRow("Baud Rate:", self.baud_cb) + + self.ch_spin = QSpinBox() + self.ch_spin.setRange(1, 32) + self.ch_spin.setValue(4) + form.addRow("Channels:", self.ch_spin) + + self.fmt_cb = QComboBox() + self.fmt_cb.addItems(["key:val", "csv", "json"]) + form.addRow("Parse Format:", self.fmt_cb) + + self.sim_chk = QCheckBox("Simulation mode") + self.sim_chk.setChecked(True) + form.addRow(self.sim_chk) + + lay.addLayout(form) + + # Port scanner + scan_grp = QGroupBox("Available Serial Ports") + scan_lay = QVBoxLayout(scan_grp) + scan_lay.setSpacing(4) + + scan_top = QHBoxLayout() + self._scan_btn = QPushButton("🔍 Scan Ports") + self._scan_btn.setObjectName("addTraceBtn") + self._scan_btn.clicked.connect(self._scan_ports) + self._scan_status = QLabel("Click Scan to detect ports") + self._scan_status.setObjectName("traceSource") + scan_top.addWidget(self._scan_btn) + scan_top.addWidget(self._scan_status, 1) + scan_lay.addLayout(scan_top) + + self._port_list = QListWidget() + self._port_list.setMaximumHeight(100) + self._port_list.setObjectName("portList") + self._port_list.itemClicked.connect(self._on_port_selected) + self._port_list.setToolTip("Click a port to select it") + scan_lay.addWidget(self._port_list) + + hint = QLabel("↑ Click a port above to fill the Port field") + hint.setObjectName("traceSource") + scan_lay.addWidget(hint) + lay.addWidget(scan_grp) + + self._scanner = None + + def _scan_ports(self): + self._scan_btn.setEnabled(False) + self._scan_status.setText("Scanning…") + self._port_list.clear() + self._scanner = PortScanThread() + self._scanner.ports_found.connect(self._on_ports_found) + self._scanner.start() + + def _on_ports_found(self, ports): + self._scan_btn.setEnabled(True) + self._port_list.clear() + if not ports: + self._scan_status.setText("No ports found") + item = QListWidgetItem(" No serial ports detected") + item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsSelectable) + self._port_list.addItem(item) + else: + self._scan_status.setText(f"{len(ports)} port(s) found") + for device, desc in ports: + label = f" {device}" + if desc and desc != device: + label += f" — {desc}" + item = QListWidgetItem(label) + item.setData(Qt.ItemDataRole.UserRole, device) + self._port_list.addItem(item) + + def _on_port_selected(self, item: QListWidgetItem): + port = item.data(Qt.ItemDataRole.UserRole) + if port: + self.port_edit.setText(port) + + def build_device(self, device_id: str) -> SerialDevice: + return SerialDevice( + device_id=device_id, + port=self.port_edit.text().strip() or "COM3", + baud_rate=int(self.baud_cb.currentText()), + num_channels=self.ch_spin.value(), + parse_format=self.fmt_cb.currentText(), + simulate=self.sim_chk.isChecked(), + ) + + +# ── Main dialog ─────────────────────────────────────────────────────────────── + +_PANELS = { + "Analog Input": AnalogInputPanel, + "Digital I/O": DigitalIOPanel, + "Serial / UART": SerialPanel, } @@ -30,70 +268,103 @@ class AddDeviceDialog(QDialog): self.registry = registry self.created_device = None self.setWindowTitle("Add Device") - self.setMinimumWidth(360) + self.setMinimumSize(480, 460) + self.resize(500, 520) self._build() def _build(self): - layout = QVBoxLayout(self) - form = QFormLayout() + root = QVBoxLayout(self) + root.setSpacing(10) + + # Header + hdr = QLabel("Add New Device") + hdr.setObjectName("devWindowTitle") + root.addWidget(hdr) + + div = QFrame(); div.setFrameShape(QFrame.Shape.HLine) + div.setObjectName("devWindowDivider"); root.addWidget(div) + # Device type selector + type_row = QFormLayout() self._type_cb = QComboBox() - self._type_cb.addItems(list(_DEVICE_TYPES.keys())) - form.addRow("Device Type:", self._type_cb) + self._type_cb.addItems(list(_PANELS.keys())) + self._type_cb.currentIndexChanged.connect(self._on_type_changed) + type_row.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._id_edit.setPlaceholderText("Leave blank for auto (e.g. ai_1, dio_0)") + type_row.addRow("Device ID:", self._id_edit) + root.addLayout(type_row) - self._ch_spin = QSpinBox() - self._ch_spin.setRange(1, 16) - self._ch_spin.setValue(4) - form.addRow("# Channels:", self._ch_spin) + div2 = QFrame(); div2.setFrameShape(QFrame.Shape.HLine) + div2.setObjectName("devWindowDivider"); root.addWidget(div2) - self._sim_chk = QCheckBox("Simulation mode (no hardware required)") - self._sim_chk.setChecked(True) - form.addRow(self._sim_chk) + # Stacked type-specific panels + self._stack = QStackedWidget() + self._panels = {} + for name, cls in _PANELS.items(): + panel = cls() + self._panels[name] = panel + self._stack.addWidget(panel) + root.addWidget(self._stack, 1) - layout.addLayout(form) + div3 = QFrame(); div3.setFrameShape(QFrame.Shape.HLine) + div3.setObjectName("devWindowDivider"); root.addWidget(div3) - btns = QHBoxLayout() - btns.addStretch() + # Buttons + btn_row = QHBoxLayout() + btn_row.addStretch() cancel = QPushButton("Cancel") cancel.clicked.connect(self.reject) - add = QPushButton("Add Device") - add.setDefault(True) + add = QPushButton("Add Device") add.setObjectName("applyButton") + add.setDefault(True) add.clicked.connect(self._on_add) - btns.addWidget(cancel) - btns.addWidget(add) - layout.addLayout(btns) + btn_row.addWidget(cancel) + btn_row.addWidget(add) + root.addLayout(btn_row) + + def _on_type_changed(self, idx: int): + self._stack.setCurrentIndex(idx) + # Auto-suggest a device ID based on type + type_name = self._type_cb.currentText() + prefixes = { + "Analog Input": "ai", + "Digital I/O": "dio", + "Serial / UART": "ser", + } + prefix = prefixes.get(type_name, "dev") + existing = {d.info.device_id for d in self.registry.all_instances()} + for i in range(100): + candidate = f"{prefix}_{i}" + if candidate not in existing: + self._id_edit.setPlaceholderText(f"e.g. {candidate} (auto)") + break def _on_add(self): - label = self._type_cb.currentText() - cls, kw = _DEVICE_TYPES[label] - dev_id = self._id_edit.text().strip() + type_name = self._type_cb.currentText() + panel = self._panels[type_name] + dev_id = self._id_edit.text().strip() + # Auto-generate ID if blank if not dev_id: - base = kw.get("backend", "dev") + prefixes = {"Analog Input": "ai", "Digital I/O": "dio", "Serial / UART": "ser"} + prefix = prefixes.get(type_name, "dev") existing = {d.info.device_id for d in self.registry.all_instances()} for i in range(100): - candidate = f"{base}_{i}" + candidate = f"{prefix}_{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.") + f"A device with ID '{dev_id}' already exists.\n" + f"Choose a different ID.") return try: - dev = cls( - device_id=dev_id, - num_channels=self._ch_spin.value(), - simulate=self._sim_chk.isChecked(), - **kw, - ) + dev = panel.build_device(dev_id) self.created_device = dev self.accept() except Exception as e: diff --git a/ui/config_dialog.py b/ui/config_dialog.py index 7d87772..4976b36 100644 --- a/ui/config_dialog.py +++ b/ui/config_dialog.py @@ -16,7 +16,8 @@ class DeviceConfigDialog(QDialog): super().__init__(parent) self.device = device self.setWindowTitle(f"Configure — {device.info.name} [{device.info.device_id}]") - self.setMinimumSize(520, 460) + self.setMinimumSize(580, 520) + self.resize(640, 580) self._build() def _build(self): @@ -26,13 +27,18 @@ class DeviceConfigDialog(QDialog): tabs = QTabWidget() # ── Tab 1: Device-specific widget ──────────────────────────── + cfg_widget = self.device.get_config_widget() scroll = QScrollArea() scroll.setWidgetResizable(True) - scroll.setWidget(self.device.get_config_widget()) + scroll.setHorizontalScrollBarPolicy( + __import__("PyQt6.QtCore", fromlist=["Qt"]).Qt.ScrollBarPolicy.ScrollBarAsNeeded + ) + scroll.setMinimumWidth(460) + scroll.setWidget(cfg_widget) tabs.addTab(scroll, "Hardware / Backend") # ── Tab 2: Channel settings ─────────────────────────────────── - tabs.addTab(self._channel_tab(), "Channels & Alarms") + tabs.addTab(self._channel_tab(), "Channels") # ── Tab 3: Device info ──────────────────────────────────────── tabs.addTab(self._info_tab(), "Info") @@ -62,28 +68,21 @@ class DeviceConfigDialog(QDialog): 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) + form.addRow("Name:", name_e) + form.addRow("Unit:", unit_e) + form.addRow("Enabled:", en_chk) apply = QPushButton("Apply") apply.setObjectName("applyButton") - def _make_apply(c, ne, ue, ec, ls, hs): + def _make_apply(c, ne, ue, ec): def _do(): - c.name = ne.text() - c.unit = ue.text() - c.enabled = ec.isChecked() - c.alarm_low = ls.value() - c.alarm_high = hs.value() + c.name = ne.text() + c.unit = ue.text() + c.enabled = ec.isChecked() return _do - apply.clicked.connect(_make_apply(ch, name_e, unit_e, en_chk, lo, hi)) + apply.clicked.connect(_make_apply(ch, name_e, unit_e, en_chk)) form.addRow(apply) layout.addWidget(grp) diff --git a/ui/control_panel.py b/ui/control_panel.py new file mode 100644 index 0000000..98f0637 --- /dev/null +++ b/ui/control_panel.py @@ -0,0 +1,559 @@ +""" +ui/control_panel.py + +Left-panel Control Panel. + +Contains modular output control widgets: + • OnOffSwitch — latching power switch with indicator + • MotorControl — speed dial (0–100 %), direction toggle, start/stop + • SetpointControl — numeric target with ± step buttons and live readback + • PwmControl — PWM duty cycle slider with frequency setting + • GenericOutput — generic voltage/current analog output entry + +Each widget is self-contained and writes to a device channel via the +DeviceRegistry when a device+channel is configured. + +New widget types can be added by subclassing ControlWidget and dropping +an instance into ControlPanel.add_widget(). +""" + +from __future__ import annotations +from typing import Optional + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QPushButton, QSlider, QDoubleSpinBox, QComboBox, + QScrollArea, QFrame, QSizePolicy, QGridLayout, + QSpinBox, QGroupBox, +) +from PyQt6.QtCore import Qt, pyqtSignal, QTimer +from PyQt6.QtGui import QFont + +from devices.device_registry import DeviceRegistry + + +# ══════════════════════════════════════════════════════════════════════════════ +# Base widget +# ══════════════════════════════════════════════════════════════════════════════ + +class ControlWidget(QFrame): + """Base class for all control panel widgets.""" + + value_changed = pyqtSignal(str, float) # channel_id, value + + def __init__(self, title: str, icon: str = "⚙", + device_id: str = "", channel_id: str = "", + registry: Optional[DeviceRegistry] = None): + super().__init__() + self.title = title + self.icon = icon + self.device_id = device_id + self.channel_id = channel_id + self.registry = registry + self.setObjectName("controlWidget") + self._build_frame() + + def _build_frame(self): + """Build the common outer frame; subclasses fill self._body.""" + outer = QVBoxLayout(self) + outer.setContentsMargins(0, 0, 0, 0) + outer.setSpacing(0) + + # Header bar + hdr = QWidget() + hdr.setObjectName("controlWidgetHeader") + hdr.setFixedHeight(26) + hdr_lay = QHBoxLayout(hdr) + hdr_lay.setContentsMargins(8, 0, 8, 0) + icon_lbl = QLabel(self.icon) + icon_lbl.setObjectName("controlWidgetIcon") + title_lbl = QLabel(self.title.upper()) + title_lbl.setObjectName("controlWidgetTitle") + hdr_lay.addWidget(icon_lbl) + hdr_lay.addWidget(title_lbl, 1) + outer.addWidget(hdr) + + # Body — subclasses populate this + self._body = QWidget() + self._body.setObjectName("controlWidgetBody") + body_lay = QVBoxLayout(self._body) + body_lay.setContentsMargins(10, 8, 10, 10) + body_lay.setSpacing(6) + self._body_layout = body_lay + outer.addWidget(self._body) + + def _write(self, value: float): + """Write value to the linked device channel (if any).""" + if self.registry and self.device_id and self.channel_id: + dev = self.registry.get_instance(self.device_id) + if dev: + dev.write_channel(self.channel_id, value) + self.value_changed.emit(self.channel_id, value) + + +# ══════════════════════════════════════════════════════════════════════════════ +# On/Off Switch +# ══════════════════════════════════════════════════════════════════════════════ + +class OnOffSwitch(ControlWidget): + """Large latching power switch with green/red indicator.""" + + def __init__(self, title: str = "Power", **kw): + super().__init__(title, icon="⏻", **kw) + self._state = False + self._build_body() + + def _build_body(self): + lay = self._body_layout + + # Big toggle button + self._btn = QPushButton("OFF") + self._btn.setObjectName("switchBtn") + self._btn.setCheckable(True) + self._btn.setMinimumHeight(52) + self._btn.toggled.connect(self._on_toggle) + lay.addWidget(self._btn) + + # Status indicator row + ind_row = QHBoxLayout() + self._indicator = QLabel("●") + self._indicator.setObjectName("switchIndicatorOff") + self._state_lbl = QLabel("INACTIVE") + self._state_lbl.setObjectName("switchStateLbl") + ind_row.addStretch() + ind_row.addWidget(self._indicator) + ind_row.addWidget(self._state_lbl) + ind_row.addStretch() + lay.addLayout(ind_row) + + def _on_toggle(self, checked: bool): + self._state = checked + if checked: + self._btn.setText("ON") + self._btn.setObjectName("switchBtnOn") + self._indicator.setObjectName("switchIndicatorOn") + self._state_lbl.setText("ACTIVE") + else: + self._btn.setText("OFF") + self._btn.setObjectName("switchBtnOff") + self._indicator.setObjectName("switchIndicatorOff") + self._state_lbl.setText("INACTIVE") + # Re-polish so QSS picks up new objectName + for w in (self._btn, self._indicator, self._state_lbl): + w.style().unpolish(w); w.style().polish(w) + self._write(1.0 if checked else 0.0) + + +# ══════════════════════════════════════════════════════════════════════════════ +# Motor Control +# ══════════════════════════════════════════════════════════════════════════════ + +class MotorControl(ControlWidget): + """Motor speed (0–100 %), direction toggle, start/stop.""" + + def __init__(self, title: str = "Motor", max_rpm: int = 3000, **kw): + super().__init__(title, icon="⟳", **kw) + self.max_rpm = max_rpm + self._running = False + self._fwd = True + self._build_body() + + def _build_body(self): + lay = self._body_layout + + # Speed readout + readout_row = QHBoxLayout() + self._speed_lbl = QLabel("0") + self._speed_lbl.setObjectName("motorSpeedLbl") + unit_lbl = QLabel("RPM") + unit_lbl.setObjectName("motorUnitLbl") + readout_row.addStretch() + readout_row.addWidget(self._speed_lbl) + readout_row.addWidget(unit_lbl) + readout_row.addStretch() + lay.addLayout(readout_row) + + # Speed slider + self._slider = QSlider(Qt.Orientation.Horizontal) + self._slider.setRange(0, 100) + self._slider.setValue(0) + self._slider.setObjectName("motorSlider") + self._slider.valueChanged.connect(self._on_speed) + lay.addWidget(self._slider) + + # Pct label + self._pct_lbl = QLabel("0 %") + self._pct_lbl.setObjectName("motorPctLbl") + self._pct_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) + lay.addWidget(self._pct_lbl) + + # Direction + start/stop row + btn_row = QHBoxLayout() + + self._dir_btn = QPushButton("▶ FWD") + self._dir_btn.setObjectName("motorDirBtn") + self._dir_btn.setCheckable(True) + self._dir_btn.toggled.connect(self._on_dir) + btn_row.addWidget(self._dir_btn) + + self._run_btn = QPushButton("START") + self._run_btn.setObjectName("motorRunBtnOff") + self._run_btn.setCheckable(True) + self._run_btn.toggled.connect(self._on_run) + btn_row.addWidget(self._run_btn) + + lay.addLayout(btn_row) + + def _on_speed(self, pct: int): + rpm = int(pct / 100.0 * self.max_rpm) + self._speed_lbl.setText(str(rpm)) + self._pct_lbl.setText(f"{pct} %") + if self._running: + self._write(float(rpm) * (1 if self._fwd else -1)) + + def _on_dir(self, rev: bool): + self._fwd = not rev + self._dir_btn.setText("◀ REV" if rev else "▶ FWD") + self._dir_btn.setObjectName("motorDirBtnRev" if rev else "motorDirBtn") + self._dir_btn.style().unpolish(self._dir_btn) + self._dir_btn.style().polish(self._dir_btn) + self._on_speed(self._slider.value()) + + def _on_run(self, running: bool): + self._running = running + self._run_btn.setText("STOP" if running else "START") + self._run_btn.setObjectName("motorRunBtnOn" if running else "motorRunBtnOff") + self._run_btn.style().unpolish(self._run_btn) + self._run_btn.style().polish(self._run_btn) + if not running: + self._write(0.0) + else: + self._on_speed(self._slider.value()) + + +# ══════════════════════════════════════════════════════════════════════════════ +# Setpoint Control +# ══════════════════════════════════════════════════════════════════════════════ + +class SetpointControl(ControlWidget): + """Numeric setpoint with ± step buttons and live process-value readback.""" + + def __init__(self, title: str = "Setpoint", unit: str = "", + min_val: float = 0.0, max_val: float = 100.0, + step: float = 1.0, **kw): + super().__init__(title, icon="◎", **kw) + self.unit = unit + self.min_val = min_val + self.max_val = max_val + self.step = step + self._pv = 0.0 + self._build_body() + + def _build_body(self): + lay = self._body_layout + + # SP row + sp_row = QHBoxLayout() + sp_lbl = QLabel("SP") + sp_lbl.setObjectName("spLabel") + sp_lbl.setFixedWidth(24) + + self._minus_btn = QPushButton("−") + self._minus_btn.setObjectName("spStepBtn") + self._minus_btn.setFixedWidth(32) + self._minus_btn.clicked.connect(self._decrement) + + self._sp_spin = QDoubleSpinBox() + self._sp_spin.setRange(self.min_val, self.max_val) + self._sp_spin.setSingleStep(self.step) + self._sp_spin.setSuffix(f" {self.unit}") + self._sp_spin.setObjectName("spSpinbox") + self._sp_spin.valueChanged.connect(self._on_sp_changed) + + self._plus_btn = QPushButton("+") + self._plus_btn.setObjectName("spStepBtn") + self._plus_btn.setFixedWidth(32) + self._plus_btn.clicked.connect(self._increment) + + sp_row.addWidget(sp_lbl) + sp_row.addWidget(self._minus_btn) + sp_row.addWidget(self._sp_spin, 1) + sp_row.addWidget(self._plus_btn) + lay.addLayout(sp_row) + + # PV readback row + pv_row = QHBoxLayout() + pv_lbl = QLabel("PV") + pv_lbl.setObjectName("pvLabel") + pv_lbl.setFixedWidth(24) + self._pv_lbl = QLabel(f"— {self.unit}") + self._pv_lbl.setObjectName("pvValue") + pv_row.addWidget(pv_lbl) + pv_row.addWidget(self._pv_lbl, 1) + lay.addLayout(pv_row) + + # Error bar + err_row = QHBoxLayout() + err_lbl = QLabel("ERR") + err_lbl.setObjectName("errLabel") + err_lbl.setFixedWidth(24) + self._err_lbl = QLabel("—") + self._err_lbl.setObjectName("errValue") + err_row.addWidget(err_lbl) + err_row.addWidget(self._err_lbl, 1) + lay.addLayout(err_row) + + def update_pv(self, value: float): + """Call this with live process-variable readings.""" + self._pv = value + self._pv_lbl.setText(f"{value:.3f} {self.unit}") + err = self._sp_spin.value() - value + self._err_lbl.setText(f"{err:+.3f} {self.unit}") + self._err_lbl.setStyleSheet( + "color:#ef4444;" if abs(err) > 1.0 else "color:#22c55e;" + ) + + def _on_sp_changed(self, v: float): + self._write(v) + + def _increment(self): + self._sp_spin.setValue(self._sp_spin.value() + self.step) + + def _decrement(self): + self._sp_spin.setValue(self._sp_spin.value() - self.step) + + +# ══════════════════════════════════════════════════════════════════════════════ +# PWM Control +# ══════════════════════════════════════════════════════════════════════════════ + +class PwmControl(ControlWidget): + """PWM duty cycle slider + frequency setting.""" + + def __init__(self, title: str = "PWM Output", **kw): + super().__init__(title, icon="⊓", **kw) + self._build_body() + + def _build_body(self): + lay = self._body_layout + + # Duty cycle + dc_row = QHBoxLayout() + dc_lbl = QLabel("Duty:") + dc_lbl.setObjectName("pwmLabel") + self._dc_lbl = QLabel("0 %") + self._dc_lbl.setObjectName("pwmValue") + dc_row.addWidget(dc_lbl) + dc_row.addStretch() + dc_row.addWidget(self._dc_lbl) + lay.addLayout(dc_row) + + self._dc_slider = QSlider(Qt.Orientation.Horizontal) + self._dc_slider.setRange(0, 100) + self._dc_slider.setValue(0) + self._dc_slider.setObjectName("pwmSlider") + self._dc_slider.valueChanged.connect(self._on_dc) + lay.addWidget(self._dc_slider) + + # Frequency + freq_row = QHBoxLayout() + freq_lbl = QLabel("Freq:") + freq_lbl.setObjectName("pwmLabel") + self._freq_spin = QSpinBox() + self._freq_spin.setRange(1, 100_000) + self._freq_spin.setValue(1000) + self._freq_spin.setSuffix(" Hz") + self._freq_spin.setObjectName("pwmFreqSpin") + self._freq_spin.valueChanged.connect(self._on_freq) + freq_row.addWidget(freq_lbl) + freq_row.addWidget(self._freq_spin) + lay.addLayout(freq_row) + + # Enable + self._en_btn = QPushButton("ENABLE") + self._en_btn.setObjectName("motorRunBtnOff") + self._en_btn.setCheckable(True) + self._en_btn.toggled.connect(self._on_enable) + lay.addWidget(self._en_btn) + + def _on_dc(self, v: int): + self._dc_lbl.setText(f"{v} %") + if self._en_btn.isChecked(): + self._write(float(v)) + + def _on_freq(self, hz: int): + pass # TODO: send freq command to hardware + + def _on_enable(self, en: bool): + self._en_btn.setText("DISABLE" if en else "ENABLE") + self._en_btn.setObjectName("motorRunBtnOn" if en else "motorRunBtnOff") + self._en_btn.style().unpolish(self._en_btn) + self._en_btn.style().polish(self._en_btn) + self._write(float(self._dc_slider.value()) if en else 0.0) + + +# ══════════════════════════════════════════════════════════════════════════════ +# Generic Analog Output +# ══════════════════════════════════════════════════════════════════════════════ + +class AnalogOutputControl(ControlWidget): + """Generic voltage/current analog output with spinbox + send button.""" + + def __init__(self, title: str = "Analog Out", unit: str = "V", + min_val: float = 0.0, max_val: float = 10.0, **kw): + super().__init__(title, icon="↗", **kw) + self.unit = unit + self.min_val = min_val + self.max_val = max_val + self._build_body() + + def _build_body(self): + lay = self._body_layout + + val_row = QHBoxLayout() + self._spin = QDoubleSpinBox() + self._spin.setRange(self.min_val, self.max_val) + self._spin.setSingleStep(0.01) + self._spin.setSuffix(f" {self.unit}") + self._spin.setObjectName("spSpinbox") + val_row.addWidget(self._spin, 1) + + send_btn = QPushButton("SET") + send_btn.setObjectName("applyButton") + send_btn.clicked.connect(lambda: self._write(self._spin.value())) + val_row.addWidget(send_btn) + lay.addLayout(val_row) + + # Slider mirror + self._slider = QSlider(Qt.Orientation.Horizontal) + self._slider.setRange(0, 1000) + self._slider.setObjectName("motorSlider") + self._slider.valueChanged.connect(self._on_slider) + self._spin.valueChanged.connect(self._sync_slider) + lay.addWidget(self._slider) + + def _on_slider(self, v: int): + mapped = self.min_val + (v / 1000.0) * (self.max_val - self.min_val) + self._spin.blockSignals(True) + self._spin.setValue(mapped) + self._spin.blockSignals(False) + + def _sync_slider(self, v: float): + rng = self.max_val - self.min_val + norm = int(((v - self.min_val) / rng) * 1000) if rng else 0 + self._slider.blockSignals(True) + self._slider.setValue(max(0, min(1000, norm))) + self._slider.blockSignals(False) + + +# ══════════════════════════════════════════════════════════════════════════════ +# Control Panel container +# ══════════════════════════════════════════════════════════════════════════════ + +class ControlPanel(QWidget): + """Left panel — output control widgets only. No device management here.""" + + def __init__(self, registry: DeviceRegistry): + super().__init__() + self.registry = registry + self._widgets: list[ControlWidget] = [] + self._build() + self._add_demo_widgets() + + def _build(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + hdr = QLabel(" CONTROLS") + hdr.setObjectName("panelHeader") + hdr.setMinimumHeight(28) + layout.addWidget(hdr) + + 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(10) + self._inner.addStretch() + + scroll.setWidget(self._container) + layout.addWidget(scroll) + + def add_widget(self, widget: ControlWidget): + """Add a control widget to the panel.""" + self._widgets.append(widget) + self._inner.insertWidget(self._inner.count() - 1, widget) + + def clear_widgets(self): + """Remove all control widgets.""" + for w in self._widgets: + self._inner.removeWidget(w) + w.deleteLater() + self._widgets.clear() + + def _add_demo_widgets(self): + """Default demo configuration — replace with your lab setup.""" + + # Pump power switch + pump = OnOffSwitch( + title="Pump Power", + registry=self.registry, + device_id="dio_0", channel_id="do0", + ) + self.add_widget(pump) + + # Heater switch + heater = OnOffSwitch( + title="Heater", + registry=self.registry, + device_id="dio_0", channel_id="do1", + ) + self.add_widget(heater) + + # Motor controller + motor = MotorControl( + title="Drive Motor", + max_rpm=3000, + registry=self.registry, + device_id="ard_0", channel_id="A0", + ) + self.add_widget(motor) + + # Temperature setpoint + temp_sp = SetpointControl( + title="Temp Setpoint", + unit="°C", + min_val=0.0, max_val=300.0, + step=0.5, + ) + self.add_widget(temp_sp) + + # Flow setpoint + flow_sp = SetpointControl( + title="Flow Rate", + unit="mL/min", + min_val=0.0, max_val=500.0, + step=5.0, + ) + self.add_widget(flow_sp) + + # PWM output + pwm = PwmControl( + title="PWM Ch 1", + registry=self.registry, + device_id="dio_0", channel_id="do2", + ) + self.add_widget(pwm) + + # Analog output + ao = AnalogOutputControl( + title="Analog Out", + unit="V", + min_val=0.0, max_val=10.0, + ) + self.add_widget(ao) diff --git a/ui/devices_window.py b/ui/devices_window.py new file mode 100644 index 0000000..0548330 --- /dev/null +++ b/ui/devices_window.py @@ -0,0 +1,224 @@ +""" +ui/devices_window.py + +Floating "Devices" settings window. +Opens from the toolbar ⚙ DEVICES button; can stay open alongside the main window. +Contains: device status cards, Add Device, and per-device Configure. +""" + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QPushButton, QScrollArea, QFrame, QSizePolicy, + QDialog, +) +from PyQt6.QtCore import Qt, pyqtSignal, QTimer +from PyQt6.QtGui import QCloseEvent + +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: 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) + + # 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")) + self._apply_status_style() + layout.addWidget(self._status_lbl) + + # Channel count · type + 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) + + # Configure 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;" + self._dot.setStyleSheet(dot_style) + self._status_lbl.setText(_STATUS_LABEL.get(self.device.status, "UNKNOWN")) + self._apply_status_style() + + def _apply_status_style(self): + s = _STATUS_STYLE.get(self.device.status, "color:#475569;") + self._status_lbl.setStyleSheet( + s + " font-family:'IBM Plex Mono',monospace;" + " font-size:10px; font-weight:700; letter-spacing:1px;" + ) + + +class DevicesWindow(QWidget): + """ + Standalone floating window for device management. + + Signals + ------- + config_requested(device_id) — user clicked Configure on a card + device_added() — a new device was added via the dialog + closed() — window was closed (so toolbar btn can untoggle) + """ + + config_requested = pyqtSignal(str) + device_added = pyqtSignal() + closed = pyqtSignal() + + def __init__(self, registry: DeviceRegistry, engine: AcquisitionEngine, parent=None): + super().__init__( + parent, + # Acts like a real standalone window but stays on top of parent + Qt.WindowType.Window | + Qt.WindowType.Tool, + ) + self.registry = registry + self.engine = engine + self._cards: dict = {} + + self.setWindowTitle("Devices") + self.setMinimumSize(300, 480) + self.resize(320, 600) + self._build() + self.refresh() + + self._timer = QTimer(self) + self._timer.setInterval(2000) + self._timer.timeout.connect(self._refresh_status) + self._timer.start() + + # ── Build ───────────────────────────────────────────────────────────── + + def _build(self): + root = QVBoxLayout(self) + root.setContentsMargins(0, 0, 0, 0) + root.setSpacing(0) + + # ── Title bar row ────────────────────────────────────────────── + title_bar = QWidget() + title_bar.setObjectName("devWindowTitleBar") + title_bar.setFixedHeight(40) + tb_lay = QHBoxLayout(title_bar) + tb_lay.setContentsMargins(12, 0, 8, 0) + + title_lbl = QLabel("⚙ DEVICES") + title_lbl.setObjectName("devWindowTitle") + tb_lay.addWidget(title_lbl, 1) + + self._add_btn = QPushButton("+ Add") + self._add_btn.setObjectName("addDeviceButton") + self._add_btn.clicked.connect(self._on_add) + tb_lay.addWidget(self._add_btn) + + root.addWidget(title_bar) + + # ── Divider ──────────────────────────────────────────────────── + div = QFrame() + div.setFrameShape(QFrame.Shape.HLine) + div.setObjectName("devWindowDivider") + root.addWidget(div) + + # ── Scrollable device cards ──────────────────────────────────── + scroll = QScrollArea() + scroll.setObjectName("deviceScroll") + scroll.setWidgetResizable(True) + scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + + self._container = QWidget() + self._inner = QVBoxLayout(self._container) + self._inner.setContentsMargins(10, 10, 10, 10) + self._inner.setSpacing(10) + self._inner.addStretch() + + scroll.setWidget(self._container) + root.addWidget(scroll) + + # ── Public API ──────────────────────────────────────────────────────── + + def refresh(self): + """Rebuild all device cards from current registry state.""" + 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 + + # ── Slots ──────────────────────────────────────────────────────────── + + def _on_add(self): + from ui.add_device_dialog import AddDeviceDialog + from ui.config_dialog import DeviceConfigDialog + 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.refresh() + self.device_added.emit() + + def _refresh_status(self): + for card in self._cards.values(): + card.refresh() + + def closeEvent(self, event: QCloseEvent): + self.closed.emit() + event.accept() diff --git a/ui/main_window.py b/ui/main_window.py index d329571..e69ab1a 100644 --- a/ui/main_window.py +++ b/ui/main_window.py @@ -1,214 +1,307 @@ """ -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 │ - └──────────────────────────────────────────────────────────┘ +ui/main_window.py + +Toolbar sections (left→right): + [▶ RUN] [⬤ LOG] | [⚙ DEVICES] | [⚗ SIGNALS] | [📐 PLOT] | [⚙ SETTINGS] + spacer | timer + +Central: + Left: ControlPanel (output widgets only) + Center: StripChartWidget + +All secondary features live in floating windows. """ from PyQt6.QtWidgets import ( QMainWindow, QWidget, QHBoxLayout, QVBoxLayout, QSplitter, QStatusBar, QLabel, QPushButton, - QToolBar, QSizePolicy, QMessageBox, + QToolBar, QSizePolicy, QApplication, ) from PyQt6.QtCore import Qt, QTimer, pyqtSlot +import os 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 core.signal_processor import SignalProcessor -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 +from ui.control_panel import ControlPanel +from ui.strip_chart import StripChartWidget +from ui.windows.devices_window import DevicesWindow +from ui.windows.signals_window import SignalsWindow +from ui.windows.plot_window import PlotWindow, build_default_layout +from ui.windows.settings_window import SettingsWindow + + +_DARK_QSS = os.path.join(os.path.dirname(os.path.abspath(__file__)), "style_dark.qss") +_LIGHT_QSS = os.path.join(os.path.dirname(os.path.abspath(__file__)), "style_light.qss") class MainWindow(QMainWindow): def __init__(self): super().__init__() - self.setWindowTitle("LabDAQ — Data Acquisition System") - self.setMinimumSize(1360, 820) + self.setWindowTitle("LabDAQ") + self.setMinimumSize(1000, 640) - self.registry = DeviceRegistry() - self.engine = AcquisitionEngine(poll_interval_ms=100) + self.registry = DeviceRegistry() + self.engine = AcquisitionEngine(poll_interval_ms=100) + self.processor = SignalProcessor() + self._settings = dict(SettingsWindow._defaults) self._elapsed = 0 - self._running = False + self._win_devices = None + self._win_signals = None + self._win_plot = None + self._win_settings = None self._init_demo_devices() self._build_ui() self._connect_signals() + # ── Demo ────────────────────────────────────────────────────────────── + 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]: + for dev in [ + AnalogInputDevice(device_id="ai_0", num_channels=4, simulate=True, backend="nidaqmx"), + AnalogInputDevice(device_id="ard_0", num_channels=4, simulate=True, backend="arduino"), + DigitalIODevice (device_id="dio_0", num_inputs=4, num_outputs=4, simulate=True), + SerialDevice (device_id="ser_0", num_channels=3, simulate=True), + ]: + dev.connect() self.registry.add_instance(dev) self.engine.add_device(dev) + # ── UI ──────────────────────────────────────────────────────────────── + def _build_ui(self): - tb = QToolBar("Main") - tb.setObjectName("mainToolbar") - tb.setMovable(False) + # ── Toolbar ────────────────────────────────────────────────────── + tb = QToolBar(); tb.setObjectName("mainToolbar"); tb.setMovable(False) self.addToolBar(tb) + def _sep(): + s = QFrame(); s.setFrameShape(QFrame.Shape.VLine) + s.setObjectName("toolbarSep"); return s + + # RUN / LOG 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._run_btn.setObjectName("runButton"); self._run_btn.setCheckable(True) + self._run_btn.clicked.connect(self._toggle_run); tb.addWidget(self._run_btn) 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) + 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) + tb.addWidget(_sep()) + + # DEVICES + dev_btn = QPushButton("⊞ Devices") + dev_btn.setObjectName("toolbarSectionBtn") + dev_btn.setCheckable(True) + dev_btn.clicked.connect(lambda c: self._toggle_win("devices", c, dev_btn)) + tb.addWidget(dev_btn); self._btn_devices = dev_btn + + tb.addWidget(_sep()) + + # SIGNALS + sig_btn = QPushButton("⚗ Signals") + sig_btn.setObjectName("toolbarSectionBtn") + sig_btn.setCheckable(True) + sig_btn.clicked.connect(lambda c: self._toggle_win("signals", c, sig_btn)) + tb.addWidget(sig_btn); self._btn_signals = sig_btn + + tb.addWidget(_sep()) - spacer = QWidget() - spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + # PLOT + plot_btn = QPushButton("📐 Plot") + plot_btn.setObjectName("toolbarSectionBtn") + plot_btn.setCheckable(True) + plot_btn.clicked.connect(lambda c: self._toggle_win("plot", c, plot_btn)) + tb.addWidget(plot_btn); self._btn_plot = plot_btn + + tb.addWidget(_sep()) + + # SETTINGS + set_btn = QPushButton("⚙ Settings") + set_btn.setObjectName("toolbarSectionBtn") + set_btn.setCheckable(True) + set_btn.clicked.connect(lambda c: self._toggle_win("settings", c, set_btn)) + tb.addWidget(set_btn); self._btn_settings = set_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") + 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) + # ── Central ─────────────────────────────────────────────────────── + 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) + 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._ctrl = ControlPanel(self.registry) + self._ctrl.setMinimumWidth(200); self._ctrl.setMaximumWidth(320) + hsplit.addWidget(self._ctrl) - self._chart = StripChartWidget(self.engine, self.registry) + self._chart = StripChartWidget(self.engine, self.registry, self.processor) 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]) + hsplit.setSizes([240, 1000]) 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) + # ── Status bar ───────────────────────────────────────────────── + sb = QStatusBar(); self.setStatusBar(sb) + self._status = QLabel("Ready"); sb.addWidget(self._status) + self._log_lbl = QLabel(""); sb.addPermanentWidget(self._log_lbl) - self._clock = QTimer(self) - self._clock.setInterval(1000) + 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): + self.engine.new_data.connect(self.processor.on_raw_data) + self.processor.processed_data.connect(self._chart.on_new_data) + self.engine.log_started.connect(lambda p: self._log_lbl.setText(f"● {p}")) + self.engine.log_stopped.connect(lambda p: self._log_lbl.setText(f"✓ {p}")) + + # ── Window management ───────────────────────────────────────────────── + + def _toggle_win(self, name: str, checked: bool, btn: QPushButton): + creators = { + "devices": self._open_devices, + "signals": self._open_signals, + "plot": self._open_plot, + "settings": self._open_settings, + } + wins = { + "devices": "_win_devices", + "signals": "_win_signals", + "plot": "_win_plot", + "settings": "_win_settings", + } if checked: + creators[name]() + else: + win = getattr(self, wins[name], None) + if win: win.hide() + + def _open_devices(self): + if self._win_devices is None: + self._win_devices = DevicesWindow(self.registry, self.engine, self) + self._win_devices.device_added.connect(self._on_device_added) + self._win_devices.device_removed.connect(self._on_device_removed) + self._win_devices.device_reconfigured.connect(self._on_device_reconfigured) + self._win_devices.closed.connect(lambda: self._btn_devices.setChecked(False)) + self._show_win(self._win_devices, "right") + + def _open_signals(self): + if self._win_signals is None: + self._win_signals = SignalsWindow(self.registry, self.processor, self) + self._win_signals.derived_changed.connect(self._on_derived_changed) + self._win_signals.visibility_changed.connect(self._chart.refresh) + self._win_signals.closed.connect(lambda: self._btn_signals.setChecked(False)) + self._show_win(self._win_signals, "right") + + def _open_plot(self): + if self._win_plot is None: + self._win_plot = PlotWindow(self.registry, self.processor, + self._chart._cfg, self) + self._win_plot.layout_applied.connect(self._chart.apply_layout) + self._win_plot.closed.connect(lambda: self._btn_plot.setChecked(False)) + else: + self._win_plot.cfg = self._chart._cfg + self._win_plot._populate() + self._show_win(self._win_plot, "below") + + def _open_settings(self): + if self._win_settings is None: + self._win_settings = SettingsWindow(self.registry, self.engine, + self._settings, self) + self._win_settings.theme_changed.connect(self._apply_theme) + self._win_settings.settings_changed.connect(self._on_settings) + self._win_settings.closed.connect(lambda: self._btn_settings.setChecked(False)) + self._show_win(self._win_settings, "right") + + def _show_win(self, win: QWidget, position: str = "right"): + geo = self.geometry() + if not win.isVisible(): + if position == "right": + win.move(geo.right() + 8, geo.top() + 40) + elif position == "below": + win.move(geo.left(), geo.bottom() + 8) + win.show(); win.raise_(); win.activateWindow() + + # ── Device/signal events ────────────────────────────────────────────── + + def _on_device_added(self): + self._chart.refresh() + if self._win_plot: self._win_plot.refresh_channels() + self._status.setText("Device added.") + + def _on_device_removed(self, dev_id: str): + self._chart.refresh() + self._status.setText(f"Device '{dev_id}' removed.") + + def _on_device_reconfigured(self, dev_id: str): + """Called after Configure dialog closes — rebuild chart curves.""" + self._chart.refresh() + if self._win_plot: + self._win_plot.refresh_channels() + self._status.setText(f"Device '{dev_id}' reconfigured.") + + def _on_derived_changed(self): + self._chart.refresh() + if self._win_plot: self._win_plot.refresh_channels() + + # ── Run / Log ───────────────────────────────────────────────────────── + + def _toggle_run(self, c: bool): + if c: self.engine.start() - self._run_btn.setText("⏹ STOP") - self._log_btn.setEnabled(True) - self._clock.start() - self._status_lbl.setText("Acquiring…") - self._running = True + self._run_btn.setText("⏹ STOP"); self._log_btn.setEnabled(True) + self._clock.start(); self._status.setText("Acquiring…") 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() + if self._log_btn.isChecked(): self._log_btn.setChecked(False) + self._log_btn.setEnabled(False); self._clock.stop() + self._status.setText("Stopped") + + def _toggle_log(self, c: bool): + if c: + os.makedirs(self._settings.get("log_dir","logs"), exist_ok=True) + p = self.engine.start_logging( + os.path.join(self._settings.get("log_dir","logs"), "")) self._log_btn.setText("⏹ LOGGING") - self._status_lbl.setText(f"Logging → {path}") + self._status.setText(f"Logging → {p}") 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) + self.engine.stop_logging(); self._log_btn.setText("⬤ LOG") + + # ── Settings ────────────────────────────────────────────────────────── + + def _apply_theme(self, theme: str): + qss_file = _DARK_QSS if theme == "dark" else _LIGHT_QSS + if os.path.exists(qss_file): + with open(qss_file) as f: + QApplication.instance().setStyleSheet(f.read()) + # Update pyqtgraph plot colours independently of QSS + self._chart.set_theme(theme) + + def _on_settings(self, cfg: dict): + self._settings.update(cfg) + self.engine._interval = cfg.get("poll_ms", 100) / 1000.0 def _tick(self): self._elapsed += 1 - h = self._elapsed // 3600 - m = (self._elapsed % 3600) // 60 - s = self._elapsed % 60 + 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() + for w in (self._win_devices,self._win_signals,self._win_plot,self._win_settings): + if w: w.close() + self.engine.stop(); event.accept() + + +from PyQt6.QtWidgets import QFrame diff --git a/ui/plot_builder.py b/ui/plot_builder.py new file mode 100644 index 0000000..beda116 --- /dev/null +++ b/ui/plot_builder.py @@ -0,0 +1,506 @@ +""" +ui/plot_builder.py + +Plot Builder — floating window for operators to: + • Create named subplot rows + • Drag channels (physical + derived) into any row + • Name each axis, set Y range, grid, color + • Reorder rows with ▲▼ arrows + • Toggle per-trace visibility + • Export / Import config as JSON + +Emits config_applied(ChartConfig) to rebuild the strip chart. +""" + +from __future__ import annotations +import json +from copy import deepcopy +from dataclasses import dataclass, field, asdict +from typing import List, Optional, Tuple + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, + QScrollArea, QFrame, QLineEdit, QDoubleSpinBox, + QCheckBox, QComboBox, QColorDialog, QToolButton, + QSizePolicy, QFileDialog, QMessageBox, QSpinBox, +) +from PyQt6.QtCore import Qt, pyqtSignal +from PyQt6.QtGui import QColor, QFont, QCloseEvent + +from devices.device_registry import DeviceRegistry +from core.signal_processor import SignalProcessor + + +# ══════════════════════════════════════════════════════════════════════════════ +# Data model (same as before, kept self-contained for clarity) +# ══════════════════════════════════════════════════════════════════════════════ + +@dataclass +class TraceSpec: + device_id: str + channel_id: str + label: str = "" + color: str = "#00d4ff" + width: float = 1.8 + visible: bool = True + style: str = "solid" # solid | dash | dot + +@dataclass +class PlotSpec: + title: str = "Plot" + y_label: str = "" + y_auto: bool = True + y_min: float = -10.0 + y_max: float = 10.0 + grid: bool = True + height: int = 1 # relative weight in splitter + traces: List[TraceSpec] = field(default_factory=list) + +@dataclass +class LayoutConfig: + plots: List[PlotSpec] = field(default_factory=list) + time_window_s: float = 30.0 + link_x: bool = True + show_legend: bool = True + + def to_json(self) -> str: + return json.dumps(asdict(self), indent=2) + + @staticmethod + def from_json(s: str) -> "LayoutConfig": + d = json.loads(s) + plots = [] + for p in d.get("plots", []): + traces = [TraceSpec(**t) for t in p.pop("traces", [])] + plots.append(PlotSpec(**p, traces=traces)) + d.pop("plots") + return LayoutConfig(**d, plots=plots) + + +def build_default_layout(registry: DeviceRegistry, + processor: SignalProcessor) -> LayoutConfig: + cfg = LayoutConfig() + for dev in registry.all_instances(): + enabled = [ch for ch in dev.info.channels if ch.enabled] + if not enabled: continue + p = PlotSpec(title=dev.info.name, + y_label=enabled[0].unit if enabled else "") + for ch in enabled: + p.traces.append(TraceSpec( + device_id=dev.info.device_id, + channel_id=ch.channel_id, + label=ch.name, color=ch.color, + )) + if p.traces: cfg.plots.append(p) + # Add derived channels + for dc in processor.get_derived(): + p = PlotSpec(title=dc.name, y_label=dc.unit) + p.traces.append(TraceSpec( + device_id="derived", channel_id=dc.channel_id, + label=dc.name, color=dc.color, + )) + cfg.plots.append(p) + return cfg + + +# ══════════════════════════════════════════════════════════════════════════════ +# Color swatch button +# ══════════════════════════════════════════════════════════════════════════════ + +class ColorSwatch(QPushButton): + color_changed = pyqtSignal(str) + + def __init__(self, color: str = "#00d4ff"): + super().__init__() + self._color = color + self.setFixedSize(26, 20) + self._paint() + self.clicked.connect(self._pick) + + def _paint(self): + self.setStyleSheet( + f"QPushButton{{background:{self._color};border:1px solid #2a3558;" + f"border-radius:3px;}}QPushButton:hover{{border-color:#3b82f6;}}") + + def _pick(self): + c = QColorDialog.getColor(QColor(self._color), self) + if c.isValid(): + self._color = c.name(); self._paint() + self.color_changed.emit(self._color) + + @property + def color(self): return self._color + @color.setter + def color(self, v): + self._color = v; self._paint() + + +# ══════════════════════════════════════════════════════════════════════════════ +# Trace row inside a plot block +# ══════════════════════════════════════════════════════════════════════════════ + +class TraceRow(QFrame): + removed = pyqtSignal(object) + changed = pyqtSignal() + + def __init__(self, trace: TraceSpec): + super().__init__() + self.trace = trace + self.setObjectName("traceRow") + self._build() + + def _build(self): + lay = QHBoxLayout(self) + lay.setContentsMargins(6, 3, 6, 3); lay.setSpacing(6) + + self._vis = QCheckBox(); self._vis.setChecked(self.trace.visible) + self._vis.setToolTip("Visible"); self._vis.toggled.connect(self._on_vis) + lay.addWidget(self._vis) + + self._color = ColorSwatch(self.trace.color) + self._color.color_changed.connect(self._on_color) + lay.addWidget(self._color) + + src = QLabel(f"{self.trace.device_id} / {self.trace.channel_id}") + src.setObjectName("traceSource"); src.setMinimumWidth(130) + lay.addWidget(src) + + self._lbl = QLineEdit(self.trace.label) + self._lbl.setObjectName("traceLabel"); self._lbl.setPlaceholderText("Label…") + self._lbl.textChanged.connect(lambda t: setattr(self.trace, "label", t)) + lay.addWidget(self._lbl, 1) + + self._style = QComboBox(); self._style.setObjectName("traceStyleCb") + self._style.addItems(["solid", "dash", "dot"]) + self._style.setCurrentText(self.trace.style); self._style.setFixedWidth(64) + self._style.currentTextChanged.connect(lambda s: setattr(self.trace, "style", s)) + lay.addWidget(self._style) + + self._w = QDoubleSpinBox(); self._w.setObjectName("traceWidthSpin") + self._w.setRange(0.5, 6.0); self._w.setSingleStep(0.5) + self._w.setValue(self.trace.width); self._w.setFixedWidth(56) + self._w.valueChanged.connect(lambda v: setattr(self.trace, "width", v)) + lay.addWidget(self._w) + + rm = QToolButton(); rm.setText("✕"); rm.setObjectName("traceRemoveBtn") + rm.setFixedSize(22, 22); rm.clicked.connect(lambda: self.removed.emit(self)) + lay.addWidget(rm) + + def _on_vis(self, v): self.trace.visible = v; self.changed.emit() + def _on_color(self, c): self.trace.color = c; self.changed.emit() + + +# ══════════════════════════════════════════════════════════════════════════════ +# Plot block — one collapsible row per PlotSpec +# ══════════════════════════════════════════════════════════════════════════════ + +class PlotBlock(QFrame): + removed = pyqtSignal(object) + changed = pyqtSignal() + move_up = pyqtSignal(object) + move_dn = pyqtSignal(object) + + def __init__(self, spec: PlotSpec, registry: DeviceRegistry, + processor: SignalProcessor): + super().__init__() + self.spec = spec + self.registry = registry + self.processor = processor + self._rows: List[TraceRow] = [] + self.setObjectName("plotBlock") + self._build() + + def _build(self): + outer = QVBoxLayout(self); outer.setContentsMargins(0,0,0,0); outer.setSpacing(0) + + # ── Header ──────────────────────────────────────────────────── + hdr = QWidget(); hdr.setObjectName("plotBlockHeader"); hdr.setFixedHeight(32) + hl = QHBoxLayout(hdr); hl.setContentsMargins(8,0,6,0); hl.setSpacing(4) + + self._title = QLineEdit(self.spec.title) + self._title.setObjectName("plotBlockTitle") + self._title.textChanged.connect(lambda t: (setattr(self.spec, "title", t), self.changed.emit())) + hl.addWidget(self._title, 1) + + for txt, sig in [("▲", self.move_up), ("▼", self.move_dn)]: + b = QToolButton(); b.setText(txt); b.setObjectName("plotMoveBtn") + b.setFixedSize(22,22); b.clicked.connect(lambda _, s=sig: s.emit(self)) + hl.addWidget(b) + + rm = QToolButton(); rm.setText("✕"); rm.setObjectName("plotRemoveBtn") + rm.setFixedSize(22,22); rm.clicked.connect(lambda: self.removed.emit(self)) + hl.addWidget(rm) + outer.addWidget(hdr) + + # ── Axis / range settings ───────────────────────────────────── + sett = QWidget(); sett.setObjectName("plotBlockSettings") + sl = QHBoxLayout(sett); sl.setContentsMargins(8,6,8,6); sl.setSpacing(8) + + sl.addWidget(QLabel("Y Axis:")) + self._ylabel = QLineEdit(self.spec.y_label) + self._ylabel.setObjectName("plotYLabelEdit"); self._ylabel.setMaximumWidth(130) + self._ylabel.setPlaceholderText("e.g. Voltage (V)") + self._ylabel.textChanged.connect(lambda t: setattr(self.spec, "y_label", t)) + sl.addWidget(self._ylabel) + + self._auto = QCheckBox("Auto Y"); self._auto.setChecked(self.spec.y_auto) + self._auto.toggled.connect(self._on_auto) + sl.addWidget(self._auto) + + sl.addWidget(QLabel("Min:")) + self._ymin = QDoubleSpinBox(); self._ymin.setRange(-1e9,1e9) + self._ymin.setValue(self.spec.y_min); self._ymin.setFixedWidth(72) + self._ymin.setEnabled(not self.spec.y_auto) + self._ymin.valueChanged.connect(lambda v: setattr(self.spec, "y_min", v)) + sl.addWidget(self._ymin) + + sl.addWidget(QLabel("Max:")) + self._ymax = QDoubleSpinBox(); self._ymax.setRange(-1e9,1e9) + self._ymax.setValue(self.spec.y_max); self._ymax.setFixedWidth(72) + self._ymax.setEnabled(not self.spec.y_auto) + self._ymax.valueChanged.connect(lambda v: setattr(self.spec, "y_max", v)) + sl.addWidget(self._ymax) + + self._grid = QCheckBox("Grid"); self._grid.setChecked(self.spec.grid) + self._grid.toggled.connect(lambda v: setattr(self.spec, "grid", v)) + sl.addWidget(self._grid) + + sl.addWidget(QLabel("H:")) + self._height_spin = QSpinBox(); self._height_spin.setRange(1,10) + self._height_spin.setValue(self.spec.height); self._height_spin.setFixedWidth(44) + self._height_spin.setToolTip("Relative row height") + self._height_spin.valueChanged.connect(lambda v: setattr(self.spec, "height", v)) + sl.addWidget(self._height_spin) + sl.addStretch() + outer.addWidget(sett) + + # ── Trace rows ──────────────────────────────────────────────── + traces_w = QWidget(); traces_w.setObjectName("plotBlockTraces") + self._tlay = QVBoxLayout(traces_w) + self._tlay.setContentsMargins(4,2,4,4); self._tlay.setSpacing(2) + + for tr in self.spec.traces: + self._add_trace_row(tr) + + # Channel picker + add button + pick_row = QHBoxLayout() + self._picker = self._make_picker() + pick_row.addWidget(self._picker, 1) + add_btn = QPushButton("+ Add Channel") + add_btn.setObjectName("addTraceBtn") + add_btn.clicked.connect(self._on_add_trace) + pick_row.addWidget(add_btn) + self._tlay.addLayout(pick_row) + outer.addWidget(traces_w) + + def _make_picker(self) -> QComboBox: + cb = QComboBox(); cb.setObjectName("channelPickerCb") + cb.setPlaceholderText("Select channel…") + for dev in self.registry.all_instances(): + for ch in dev.info.channels: + cb.addItem(f"{dev.info.device_id} / {ch.channel_id} ({ch.name})", + userData=(dev.info.device_id, ch.channel_id, ch.name, ch.color)) + for dc in self.processor.get_derived(): + cb.addItem(f"[derived] {dc.channel_id} ({dc.name})", + userData=("derived", dc.channel_id, dc.name, dc.color)) + return cb + + def _add_trace_row(self, trace: TraceSpec): + row = TraceRow(trace) + row.removed.connect(self._remove_trace) + row.changed.connect(self.changed) + self._rows.append(row) + self._tlay.insertWidget(self._tlay.count() - 1, row) + + def _remove_trace(self, row: TraceRow): + if row.trace in self.spec.traces: self.spec.traces.remove(row.trace) + self._rows.remove(row) + self._tlay.removeWidget(row); row.deleteLater() + self.changed.emit() + + def _on_add_trace(self): + data = self._picker.currentData() + if not data: return + dev_id, ch_id, ch_name, ch_color = data + tr = TraceSpec(device_id=dev_id, channel_id=ch_id, + label=ch_name, color=ch_color) + self.spec.traces.append(tr) + self._add_trace_row(tr) + self.changed.emit() + + def _on_auto(self, v: bool): + self.spec.y_auto = v + self._ymin.setEnabled(not v); self._ymax.setEnabled(not v) + + +# ══════════════════════════════════════════════════════════════════════════════ +# PlotBuilderWindow +# ══════════════════════════════════════════════════════════════════════════════ + +class PlotBuilderWindow(QWidget): + """ + Floating Plot Builder. Emits config_applied(LayoutConfig) on Apply. + """ + + config_applied = pyqtSignal(object) # LayoutConfig + closed = pyqtSignal() + + def __init__(self, registry: DeviceRegistry, + processor: SignalProcessor, + current: Optional[LayoutConfig] = None, + parent=None): + super().__init__(parent, Qt.WindowType.Window | Qt.WindowType.Tool) + self.registry = registry + self.processor = processor + self.cfg = deepcopy(current) if current \ + else build_default_layout(registry, processor) + + self.setWindowTitle("Plot Builder") + self.setMinimumSize(700, 560) + self.resize(800, 680) + self._blocks: List[PlotBlock] = [] + self._build() + self._populate() + + def _build(self): + root = QVBoxLayout(self); root.setContentsMargins(0,0,0,0); root.setSpacing(0) + + # ── Top bar ────────────────────────────────────────────────── + top = QWidget(); top.setObjectName("cfgTopBar"); top.setFixedHeight(44) + tl = QHBoxLayout(top); tl.setContentsMargins(12,0,12,0) + title = QLabel("📐 PLOT BUILDER"); title.setObjectName("cfgTopBarTitle") + tl.addWidget(title, 1) + self._live = QCheckBox("Live preview"); self._live.setObjectName("cfgLiveChk") + tl.addWidget(self._live) + root.addWidget(top) + + # ── Global settings ────────────────────────────────────────── + glob = QWidget(); glob.setObjectName("cfgGlobalBar") + gl = QHBoxLayout(glob); gl.setContentsMargins(12,6,12,6); gl.setSpacing(12) + gl.addWidget(QLabel("Time window:")) + self._win = QDoubleSpinBox(); self._win.setRange(1,3600) + self._win.setSuffix(" s"); self._win.setValue(self.cfg.time_window_s) + self._win.setObjectName("cfgGlobalSpin") + self._win.valueChanged.connect(self._global_changed) + gl.addWidget(self._win) + self._linkx = QCheckBox("Link X"); self._linkx.setChecked(self.cfg.link_x) + self._linkx.setObjectName("cfgLiveChk"); self._linkx.toggled.connect(self._global_changed) + gl.addWidget(self._linkx) + self._legend = QCheckBox("Legend"); self._legend.setChecked(self.cfg.show_legend) + self._legend.setObjectName("cfgLiveChk"); self._legend.toggled.connect(self._global_changed) + gl.addWidget(self._legend) + gl.addStretch() + add_row_btn = QPushButton("+ Add Plot Row"); add_row_btn.setObjectName("addDeviceButton") + add_row_btn.clicked.connect(self._add_plot) + gl.addWidget(add_row_btn) + root.addWidget(glob) + + div = QFrame(); div.setFrameShape(QFrame.Shape.HLine); div.setObjectName("devWindowDivider") + root.addWidget(div) + + # ── Scrollable plot blocks ─────────────────────────────────── + scroll = QScrollArea(); scroll.setWidgetResizable(True); scroll.setObjectName("deviceScroll") + self._container = QWidget() + self._blay = QVBoxLayout(self._container) + self._blay.setContentsMargins(10,10,10,10); self._blay.setSpacing(12) + self._blay.addStretch() + scroll.setWidget(self._container) + root.addWidget(scroll, 1) + + # ── Bottom bar ─────────────────────────────────────────────── + btm = QWidget(); btm.setObjectName("cfgBottomBar") + bl = QHBoxLayout(btm); bl.setContentsMargins(12,8,12,8) + for lbl, fn in [("⟳ Rebuild from Devices", self._reset), + ("↓ Export JSON", self._export), + ("↑ Import JSON", self._import)]: + b = QPushButton(lbl); b.setObjectName("configButton"); b.clicked.connect(fn) + bl.addWidget(b) + bl.addStretch() + ap = QPushButton("✓ Apply"); ap.setObjectName("applyButton") + ap.clicked.connect(self._apply) + bl.addWidget(ap) + root.addWidget(btm) + + def _populate(self): + for b in self._blocks: + self._blay.removeWidget(b); b.deleteLater() + self._blocks.clear() + for spec in self.cfg.plots: + self._insert_block(spec) + + def _insert_block(self, spec: PlotSpec): + blk = PlotBlock(spec, self.registry, self.processor) + blk.removed.connect(self._remove_block) + blk.changed.connect(self._maybe_live) + blk.move_up.connect(self._move_up) + blk.move_dn.connect(self._move_dn) + self._blocks.append(blk) + self._blay.insertWidget(self._blay.count() - 1, blk) + + def _add_plot(self): + spec = PlotSpec(title=f"Plot {len(self.cfg.plots)+1}") + self.cfg.plots.append(spec) + self._insert_block(spec) + self._maybe_live() + + def _remove_block(self, blk: PlotBlock): + if blk.spec in self.cfg.plots: self.cfg.plots.remove(blk.spec) + self._blocks.remove(blk) + self._blay.removeWidget(blk); blk.deleteLater() + self._maybe_live() + + def _move_up(self, blk: PlotBlock): + i = self._blocks.index(blk) + if i == 0: return + self.cfg.plots.insert(i-1, self.cfg.plots.pop(i)) + self._blocks.insert(i-1, self._blocks.pop(i)) + self._blay.removeWidget(blk); self._blay.insertWidget(i-1, blk) + self._maybe_live() + + def _move_dn(self, blk: PlotBlock): + i = self._blocks.index(blk) + if i >= len(self._blocks)-1: return + self.cfg.plots.insert(i+1, self.cfg.plots.pop(i)) + self._blocks.insert(i+1, self._blocks.pop(i)) + self._blay.removeWidget(blk); self._blay.insertWidget(i+1, blk) + self._maybe_live() + + def _global_changed(self): + self.cfg.time_window_s = self._win.value() + self.cfg.link_x = self._linkx.isChecked() + self.cfg.show_legend = self._legend.isChecked() + self._maybe_live() + + def _maybe_live(self): + if self._live.isChecked(): + self.config_applied.emit(deepcopy(self.cfg)) + + def _apply(self): + self.config_applied.emit(deepcopy(self.cfg)) + + def _reset(self): + self.cfg = build_default_layout(self.registry, self.processor) + self._win.setValue(self.cfg.time_window_s) + self._populate(); self._maybe_live() + + def _export(self): + path, _ = QFileDialog.getSaveFileName(self, "Export Layout", "layout.json", "JSON (*.json)") + if path: + open(path, "w").write(self.cfg.to_json()) + + def _import(self): + path, _ = QFileDialog.getOpenFileName(self, "Import Layout", "", "JSON (*.json)") + if path: + try: + self.cfg = LayoutConfig.from_json(open(path).read()) + self._win.setValue(self.cfg.time_window_s) + self._populate(); self._maybe_live() + except Exception as e: + QMessageBox.critical(self, "Import failed", str(e)) + + def refresh_channels(self): + """Call after devices/derived channels change.""" + self._populate() + + def closeEvent(self, event: QCloseEvent): + self.closed.emit(); event.accept() diff --git a/ui/plot_config.py b/ui/plot_config.py new file mode 100644 index 0000000..058915f --- /dev/null +++ b/ui/plot_config.py @@ -0,0 +1,629 @@ +""" +ui/plot_config.py + +Plot Configuration Builder — floating window. + +Lets the user define: + • How many subplot rows to show + • Which channels go in each row (any mix of devices/channels) + • Per-channel: label override, color, line width, visibility + • Per-plot: Y-axis label, Y min/max (or auto), grid on/off + • Global: time window, sample decimation, background color + +The resulting config is stored as a list of PlotConfig dataclasses +and emitted via the `config_applied` signal so StripChartWidget can +rebuild itself on demand. +""" + +from __future__ import annotations +import json +from copy import deepcopy +from dataclasses import dataclass, field, asdict +from typing import List, Optional + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, + QScrollArea, QFrame, QLineEdit, QDoubleSpinBox, QSpinBox, + QCheckBox, QComboBox, QColorDialog, QGroupBox, QFormLayout, + QSizePolicy, QSplitter, QListWidget, QListWidgetItem, + QAbstractItemView, QToolButton, QSlider, +) +from PyQt6.QtCore import Qt, pyqtSignal, QSize +from PyQt6.QtGui import QColor, QIcon, QCloseEvent + +from devices.device_registry import DeviceRegistry + + +# ══════════════════════════════════════════════════════════════════════════════ +# Data model +# ══════════════════════════════════════════════════════════════════════════════ + +@dataclass +class ChannelTrace: + device_id: str + channel_id: str + label: str = "" # "" → use channel name + color: str = "#00d4ff" + width: float = 1.8 + visible: bool = True + style: str = "solid" # "solid" | "dash" | "dot" + + +@dataclass +class PlotConfig: + title: str = "Plot" + y_label: str = "" + y_auto: bool = True + y_min: float = -10.0 + y_max: float = 10.0 + grid: bool = True + traces: List[ChannelTrace] = field(default_factory=list) + + +@dataclass +class ChartConfig: + plots: List[PlotConfig] = field(default_factory=list) + time_window_s: float = 30.0 + bg_color: str = "#0b0e13" + link_x: bool = True + show_legend: bool = True + + def to_json(self) -> str: + return json.dumps(asdict(self), indent=2) + + @staticmethod + def from_json(s: str) -> "ChartConfig": + d = json.loads(s) + plots = [] + for p in d.get("plots", []): + traces = [ChannelTrace(**t) for t in p.pop("traces", [])] + plots.append(PlotConfig(**p, traces=traces)) + d["plots"] = plots + return ChartConfig(**{k: v for k, v in d.items() if k != "plots"}, + plots=plots) + + +def default_config(registry: DeviceRegistry) -> ChartConfig: + """Build a sensible default config from whatever devices are registered.""" + cfg = ChartConfig() + for dev in registry.all_instances(): + if not dev.info.channels: + continue + p = PlotConfig( + title=dev.info.name, + y_label=dev.info.channels[0].unit if dev.info.channels else "", + ) + for ch in dev.info.channels: + if ch.enabled: + p.traces.append(ChannelTrace( + device_id=dev.info.device_id, + channel_id=ch.channel_id, + label=ch.name, + color=ch.color, + )) + if p.traces: + cfg.plots.append(p) + return cfg + + +# ══════════════════════════════════════════════════════════════════════════════ +# Colour swatch button +# ══════════════════════════════════════════════════════════════════════════════ + +class ColorButton(QPushButton): + color_changed = pyqtSignal(str) + + def __init__(self, color: str = "#00d4ff"): + super().__init__() + self._color = color + self.setFixedSize(28, 22) + self._apply() + self.clicked.connect(self._pick) + + def _apply(self): + self.setStyleSheet( + f"QPushButton {{ background:{self._color}; border:1px solid #2a3558;" + f" border-radius:3px; }} " + f"QPushButton:hover {{ border-color:#3b82f6; }}" + ) + + def _pick(self): + c = QColorDialog.getColor(QColor(self._color), self, "Pick colour") + if c.isValid(): + self._color = c.name() + self._apply() + self.color_changed.emit(self._color) + + @property + def color(self): return self._color + + @color.setter + def color(self, v: str): + self._color = v + self._apply() + + +# ══════════════════════════════════════════════════════════════════════════════ +# Trace row widget (one row per ChannelTrace inside a plot) +# ══════════════════════════════════════════════════════════════════════════════ + +class TraceRow(QFrame): + removed = pyqtSignal(object) # self + changed = pyqtSignal() + + def __init__(self, trace: ChannelTrace, registry: DeviceRegistry): + super().__init__() + self.trace = trace + self.registry = registry + self.setObjectName("traceRow") + self._build() + + def _build(self): + lay = QHBoxLayout(self) + lay.setContentsMargins(6, 4, 6, 4) + lay.setSpacing(6) + + # Visibility checkbox + self._vis = QCheckBox() + self._vis.setChecked(self.trace.visible) + self._vis.setToolTip("Show/hide trace") + self._vis.toggled.connect(self._on_vis) + lay.addWidget(self._vis) + + # Color swatch + self._color_btn = ColorButton(self.trace.color) + self._color_btn.color_changed.connect(self._on_color) + lay.addWidget(self._color_btn) + + # Source label dev_id / ch_id + src = QLabel(f"{self.trace.device_id} / {self.trace.channel_id}") + src.setObjectName("traceSource") + src.setMinimumWidth(120) + lay.addWidget(src) + + # Label override + self._label_edit = QLineEdit(self.trace.label) + self._label_edit.setPlaceholderText("Label…") + self._label_edit.setObjectName("traceLabel") + self._label_edit.textChanged.connect(self._on_label) + lay.addWidget(self._label_edit, 1) + + # Line style + self._style_cb = QComboBox() + self._style_cb.addItems(["solid", "dash", "dot"]) + self._style_cb.setCurrentText(self.trace.style) + self._style_cb.setObjectName("traceStyleCb") + self._style_cb.setFixedWidth(64) + self._style_cb.currentTextChanged.connect(self._on_style) + lay.addWidget(self._style_cb) + + # Width + self._width_spin = QDoubleSpinBox() + self._width_spin.setRange(0.5, 6.0) + self._width_spin.setSingleStep(0.5) + self._width_spin.setValue(self.trace.width) + self._width_spin.setFixedWidth(56) + self._width_spin.setObjectName("traceWidthSpin") + self._width_spin.valueChanged.connect(self._on_width) + lay.addWidget(self._width_spin) + + # Remove button + rm = QToolButton() + rm.setText("✕") + rm.setObjectName("traceRemoveBtn") + rm.setFixedSize(22, 22) + rm.clicked.connect(lambda: self.removed.emit(self)) + lay.addWidget(rm) + + def _on_vis(self, v): self.trace.visible = v; self.changed.emit() + def _on_color(self, c): self.trace.color = c; self.changed.emit() + def _on_label(self, t): self.trace.label = t; self.changed.emit() + def _on_style(self, s): self.trace.style = s; self.changed.emit() + def _on_width(self, w): self.trace.width = w; self.changed.emit() + + +# ══════════════════════════════════════════════════════════════════════════════ +# Plot block widget (one collapsible section per PlotConfig) +# ══════════════════════════════════════════════════════════════════════════════ + +class PlotBlock(QFrame): + removed = pyqtSignal(object) # self + changed = pyqtSignal() + move_up = pyqtSignal(object) + move_dn = pyqtSignal(object) + + def __init__(self, plot_cfg: PlotConfig, registry: DeviceRegistry, index: int): + super().__init__() + self.plot_cfg = plot_cfg + self.registry = registry + self.index = index + self.setObjectName("plotBlock") + self._trace_rows: list[TraceRow] = [] + self._build() + + def _build(self): + outer = QVBoxLayout(self) + outer.setContentsMargins(0, 0, 0, 0) + outer.setSpacing(0) + + # ── Header bar ────────────────────────────────────────────── + hdr = QWidget(); hdr.setObjectName("plotBlockHeader"); hdr.setFixedHeight(32) + hdr_lay = QHBoxLayout(hdr); hdr_lay.setContentsMargins(8, 0, 6, 0) + + self._title_edit = QLineEdit(self.plot_cfg.title) + self._title_edit.setObjectName("plotBlockTitle") + self._title_edit.textChanged.connect(self._on_title) + hdr_lay.addWidget(self._title_edit, 1) + + up_btn = QToolButton(); up_btn.setText("▲"); up_btn.setObjectName("plotMoveBtn") + up_btn.setFixedSize(22, 22); up_btn.clicked.connect(lambda: self.move_up.emit(self)) + dn_btn = QToolButton(); dn_btn.setText("▼"); dn_btn.setObjectName("plotMoveBtn") + dn_btn.setFixedSize(22, 22); dn_btn.clicked.connect(lambda: self.move_dn.emit(self)) + rm_btn = QToolButton(); rm_btn.setText("✕"); rm_btn.setObjectName("plotRemoveBtn") + rm_btn.setFixedSize(22, 22); rm_btn.clicked.connect(lambda: self.removed.emit(self)) + + for b in (up_btn, dn_btn, rm_btn): hdr_lay.addWidget(b) + outer.addWidget(hdr) + + # ── Settings row ──────────────────────────────────────────── + settings = QWidget(); settings.setObjectName("plotBlockSettings") + s_lay = QHBoxLayout(settings); s_lay.setContentsMargins(8, 6, 8, 6) + + s_lay.addWidget(QLabel("Y Label:")) + self._ylabel_edit = QLineEdit(self.plot_cfg.y_label) + self._ylabel_edit.setPlaceholderText("e.g. Voltage (V)") + self._ylabel_edit.setObjectName("plotYLabelEdit") + self._ylabel_edit.textChanged.connect(self._on_ylabel) + s_lay.addWidget(self._ylabel_edit) + + self._auto_chk = QCheckBox("Auto Y") + self._auto_chk.setChecked(self.plot_cfg.y_auto) + self._auto_chk.toggled.connect(self._on_auto) + s_lay.addWidget(self._auto_chk) + + s_lay.addWidget(QLabel("Min:")) + self._ymin = QDoubleSpinBox() + self._ymin.setRange(-1e9, 1e9); self._ymin.setValue(self.plot_cfg.y_min) + self._ymin.setFixedWidth(72); self._ymin.setEnabled(not self.plot_cfg.y_auto) + self._ymin.valueChanged.connect(self._on_ymin) + s_lay.addWidget(self._ymin) + + s_lay.addWidget(QLabel("Max:")) + self._ymax = QDoubleSpinBox() + self._ymax.setRange(-1e9, 1e9); self._ymax.setValue(self.plot_cfg.y_max) + self._ymax.setFixedWidth(72); self._ymax.setEnabled(not self.plot_cfg.y_auto) + self._ymax.valueChanged.connect(self._on_ymax) + s_lay.addWidget(self._ymax) + + self._grid_chk = QCheckBox("Grid") + self._grid_chk.setChecked(self.plot_cfg.grid) + self._grid_chk.toggled.connect(self._on_grid) + s_lay.addWidget(self._grid_chk) + + s_lay.addStretch() + outer.addWidget(settings) + + # ── Traces container ───────────────────────────────────────── + self._traces_widget = QWidget() + self._traces_widget.setObjectName("plotBlockTraces") + self._traces_layout = QVBoxLayout(self._traces_widget) + self._traces_layout.setContentsMargins(4, 2, 4, 4) + self._traces_layout.setSpacing(2) + + for tr in self.plot_cfg.traces: + self._add_trace_row(tr) + + # ── Add trace button ───────────────────────────────────────── + add_row = QHBoxLayout() + self._ch_picker = self._build_channel_picker() + add_row.addWidget(self._ch_picker, 1) + add_tr_btn = QPushButton("+ Add Trace") + add_tr_btn.setObjectName("addTraceBtn") + add_tr_btn.clicked.connect(self._on_add_trace) + add_row.addWidget(add_tr_btn) + self._traces_layout.addLayout(add_row) + + outer.addWidget(self._traces_widget) + + # ── Helpers ────────────────────────────────────────────────────────── + + def _build_channel_picker(self) -> QComboBox: + cb = QComboBox() + cb.setObjectName("channelPickerCb") + cb.setPlaceholderText("Select channel…") + for dev in self.registry.all_instances(): + for ch in dev.info.channels: + label = f"{dev.info.device_id} / {ch.channel_id} ({ch.name})" + cb.addItem(label, userData=(dev.info.device_id, ch.channel_id, + ch.name, ch.color)) + return cb + + def _add_trace_row(self, trace: ChannelTrace): + row = TraceRow(trace, self.registry) + row.removed.connect(self._remove_trace_row) + row.changed.connect(self.changed) + self._trace_rows.append(row) + # Insert before the add-row (last item) + idx = self._traces_layout.count() - 1 + self._traces_layout.insertWidget(idx, row) + + def _remove_trace_row(self, row: TraceRow): + self.plot_cfg.traces.remove(row.trace) + self._trace_rows.remove(row) + self._traces_layout.removeWidget(row) + row.deleteLater() + self.changed.emit() + + def _on_add_trace(self): + data = self._ch_picker.currentData() + if data is None: + return + dev_id, ch_id, ch_name, ch_color = data + tr = ChannelTrace(device_id=dev_id, channel_id=ch_id, + label=ch_name, color=ch_color) + self.plot_cfg.traces.append(tr) + self._add_trace_row(tr) + self.changed.emit() + + # ── Config slots ───────────────────────────────────────────────────── + + def _on_title(self, t): self.plot_cfg.title = t; self.changed.emit() + def _on_ylabel(self, t): self.plot_cfg.y_label = t; self.changed.emit() + def _on_auto(self, v): + self.plot_cfg.y_auto = v + self._ymin.setEnabled(not v); self._ymax.setEnabled(not v) + self.changed.emit() + def _on_ymin(self, v): self.plot_cfg.y_min = v; self.changed.emit() + def _on_ymax(self, v): self.plot_cfg.y_max = v; self.changed.emit() + def _on_grid(self, v): self.plot_cfg.grid = v; self.changed.emit() + + +# ══════════════════════════════════════════════════════════════════════════════ +# PlotConfigWindow — the main floating builder +# ══════════════════════════════════════════════════════════════════════════════ + +class PlotConfigWindow(QWidget): + """ + Floating plot configuration builder. + + Emits `config_applied(ChartConfig)` when the user clicks Apply or + when live-preview is enabled and any setting changes. + """ + + config_applied = pyqtSignal(object) # ChartConfig + closed = pyqtSignal() + + def __init__(self, registry: DeviceRegistry, + current_config: Optional[ChartConfig] = None, + parent=None): + super().__init__( + parent, + Qt.WindowType.Window | Qt.WindowType.Tool, + ) + self.registry = registry + self.cfg = deepcopy(current_config) if current_config \ + else default_config(registry) + + self.setWindowTitle("Plot Configuration") + self.setMinimumSize(680, 560) + self.resize(760, 640) + self._blocks: list[PlotBlock] = [] + self._build() + self._populate() + + # ── Build shell ─────────────────────────────────────────────────────── + + def _build(self): + root = QVBoxLayout(self) + root.setContentsMargins(0, 0, 0, 0) + root.setSpacing(0) + + # ── Top bar ────────────────────────────────────────────────── + topbar = QWidget(); topbar.setObjectName("cfgTopBar"); topbar.setFixedHeight(44) + tb_lay = QHBoxLayout(topbar); tb_lay.setContentsMargins(12, 0, 12, 0) + + title = QLabel("📈 PLOT CONFIGURATION") + title.setObjectName("cfgTopBarTitle") + tb_lay.addWidget(title, 1) + + self._live_chk = QCheckBox("Live preview") + self._live_chk.setChecked(False) + self._live_chk.setObjectName("cfgLiveChk") + tb_lay.addWidget(self._live_chk) + + root.addWidget(topbar) + + # ── Global settings strip ──────────────────────────────────── + glob = QWidget(); glob.setObjectName("cfgGlobalBar") + g_lay = QHBoxLayout(glob); g_lay.setContentsMargins(12, 6, 12, 6) + + g_lay.addWidget(QLabel("Time window:")) + self._win_spin = QDoubleSpinBox() + self._win_spin.setRange(1, 3600); self._win_spin.setSuffix(" s") + self._win_spin.setValue(self.cfg.time_window_s) + self._win_spin.setObjectName("cfgGlobalSpin") + self._win_spin.valueChanged.connect(self._on_global_change) + g_lay.addWidget(self._win_spin) + g_lay.addSpacing(16) + + self._linkx_chk = QCheckBox("Link X axes") + self._linkx_chk.setChecked(self.cfg.link_x) + self._linkx_chk.setObjectName("cfgLiveChk") + self._linkx_chk.toggled.connect(self._on_global_change) + g_lay.addWidget(self._linkx_chk) + + self._legend_chk = QCheckBox("Show legend") + self._legend_chk.setChecked(self.cfg.show_legend) + self._legend_chk.setObjectName("cfgLiveChk") + self._legend_chk.toggled.connect(self._on_global_change) + g_lay.addWidget(self._legend_chk) + + g_lay.addStretch() + + add_plot_btn = QPushButton("+ Add Plot Row") + add_plot_btn.setObjectName("addDeviceButton") + add_plot_btn.clicked.connect(self._add_plot) + g_lay.addWidget(add_plot_btn) + + root.addWidget(glob) + + # divider + div = QFrame(); div.setFrameShape(QFrame.Shape.HLine) + div.setObjectName("devWindowDivider"); root.addWidget(div) + + # ── Scrollable plot blocks ─────────────────────────────────── + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + scroll.setObjectName("deviceScroll") + + self._container = QWidget() + self._blocks_lay = QVBoxLayout(self._container) + self._blocks_lay.setContentsMargins(10, 10, 10, 10) + self._blocks_lay.setSpacing(12) + self._blocks_lay.addStretch() + + scroll.setWidget(self._container) + root.addWidget(scroll, 1) + + # ── Bottom action bar ──────────────────────────────────────── + btm = QWidget(); btm.setObjectName("cfgBottomBar") + b_lay = QHBoxLayout(btm); b_lay.setContentsMargins(12, 8, 12, 8) + + self._reset_btn = QPushButton("⟳ Reset to Default") + self._reset_btn.setObjectName("configButton") + self._reset_btn.clicked.connect(self._reset_defaults) + b_lay.addWidget(self._reset_btn) + + self._export_btn = QPushButton("↓ Export JSON") + self._export_btn.setObjectName("configButton") + self._export_btn.clicked.connect(self._export_json) + b_lay.addWidget(self._export_btn) + + self._import_btn = QPushButton("↑ Import JSON") + self._import_btn.setObjectName("configButton") + self._import_btn.clicked.connect(self._import_json) + b_lay.addWidget(self._import_btn) + + b_lay.addStretch() + + self._apply_btn = QPushButton("✓ Apply") + self._apply_btn.setObjectName("applyButton") + self._apply_btn.clicked.connect(self._apply) + b_lay.addWidget(self._apply_btn) + + root.addWidget(btm) + + # ── Populate from config ─────────────────────────────────────────────── + + def _populate(self): + for blk in self._blocks: + self._blocks_lay.removeWidget(blk) + blk.deleteLater() + self._blocks.clear() + + for i, p in enumerate(self.cfg.plots): + self._insert_block(p, i) + + def _insert_block(self, plot_cfg: PlotConfig, index: int): + blk = PlotBlock(plot_cfg, self.registry, index) + blk.removed.connect(self._remove_block) + blk.changed.connect(self._on_block_changed) + blk.move_up.connect(self._move_block_up) + blk.move_dn.connect(self._move_block_dn) + self._blocks.append(blk) + self._blocks_lay.insertWidget(self._blocks_lay.count() - 1, blk) + + # ── Block management ────────────────────────────────────────────────── + + def _add_plot(self): + p = PlotConfig(title=f"Plot {len(self.cfg.plots) + 1}") + self.cfg.plots.append(p) + self._insert_block(p, len(self._blocks)) + self._maybe_live() + + def _remove_block(self, blk: PlotBlock): + if blk.plot_cfg in self.cfg.plots: + self.cfg.plots.remove(blk.plot_cfg) + self._blocks.remove(blk) + self._blocks_lay.removeWidget(blk) + blk.deleteLater() + self._maybe_live() + + def _move_block_up(self, blk: PlotBlock): + i = self._blocks.index(blk) + if i == 0: return + self.cfg.plots.insert(i - 1, self.cfg.plots.pop(i)) + self._blocks.insert(i - 1, self._blocks.pop(i)) + self._blocks_lay.removeWidget(blk) + self._blocks_lay.insertWidget(i - 1, blk) + self._maybe_live() + + def _move_block_dn(self, blk: PlotBlock): + i = self._blocks.index(blk) + if i >= len(self._blocks) - 1: return + self.cfg.plots.insert(i + 1, self.cfg.plots.pop(i)) + self._blocks.insert(i + 1, self._blocks.pop(i)) + self._blocks_lay.removeWidget(blk) + self._blocks_lay.insertWidget(i + 1, blk) + self._maybe_live() + + # ── Global settings ─────────────────────────────────────────────────── + + def _on_global_change(self): + self.cfg.time_window_s = self._win_spin.value() + self.cfg.link_x = self._linkx_chk.isChecked() + self.cfg.show_legend = self._legend_chk.isChecked() + self._maybe_live() + + def _on_block_changed(self): + self._maybe_live() + + def _maybe_live(self): + if self._live_chk.isChecked(): + self.config_applied.emit(deepcopy(self.cfg)) + + # ── Actions ─────────────────────────────────────────────────────────── + + def _apply(self): + self.config_applied.emit(deepcopy(self.cfg)) + + def _reset_defaults(self): + self.cfg = default_config(self.registry) + self._win_spin.setValue(self.cfg.time_window_s) + self._populate() + self._maybe_live() + + def _export_json(self): + from PyQt6.QtWidgets import QFileDialog + path, _ = QFileDialog.getSaveFileName( + self, "Export Plot Config", "plot_config.json", + "JSON Files (*.json)" + ) + if path: + with open(path, "w") as f: + f.write(self.cfg.to_json()) + + def _import_json(self): + from PyQt6.QtWidgets import QFileDialog + path, _ = QFileDialog.getOpenFileName( + self, "Import Plot Config", "", "JSON Files (*.json)" + ) + if path: + try: + with open(path) as f: + self.cfg = ChartConfig.from_json(f.read()) + self._win_spin.setValue(self.cfg.time_window_s) + self._populate() + self._maybe_live() + except Exception as e: + from PyQt6.QtWidgets import QMessageBox + QMessageBox.critical(self, "Import failed", str(e)) + + def closeEvent(self, event: QCloseEvent): + self.closed.emit() + event.accept() + + def update_registry(self): + """Call after devices are added/removed to refresh channel pickers.""" + self._populate() diff --git a/ui/signal_builder.py b/ui/signal_builder.py new file mode 100644 index 0000000..a84d861 --- /dev/null +++ b/ui/signal_builder.py @@ -0,0 +1,605 @@ +""" +ui/signal_builder.py + +Signal Builder — operator-facing window for: + + Tab 1: FILTERS — add/remove/reorder filter stages per channel + Tab 2: DERIVED — create virtual channels (velocity, acceleration, + power, RMS, expression, custom Python) + Tab 3: CHANNELS — show/hide raw channels (replaces readout_panel settings) + +All changes are applied immediately to the SignalProcessor. +""" + +from __future__ import annotations +import traceback +from copy import deepcopy +from typing import List, Optional, Tuple + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, + QScrollArea, QFrame, QTabWidget, QComboBox, QLineEdit, + QDoubleSpinBox, QSpinBox, QCheckBox, QTextEdit, QGroupBox, + QFormLayout, QToolButton, QSizePolicy, QMessageBox, + QSplitter, QListWidget, QListWidgetItem, +) +from PyQt6.QtCore import Qt, pyqtSignal, QSize +from PyQt6.QtGui import QFont, QColor, QCloseEvent + +from devices.device_registry import DeviceRegistry +from core.signal_processor import ( + SignalProcessor, ChannelPipeline, DerivedChannel, + FilterBase, MovingAverageFilter, MedianFilter, LowPassFilter, + HighPassFilter, ScaleOffsetFilter, DerivativeFilter, IntegralFilter, + FILTER_CLASSES, +) + +# ── Color palette for new derived channels ───────────────────────────────── +_DERIVED_COLORS = [ + "#f72585", "#7209b7", "#3a0ca3", "#4361ee", + "#4cc9f0", "#f77f00", "#d62828", "#588157", +] + +# ══════════════════════════════════════════════════════════════════════════════ +# Helpers +# ══════════════════════════════════════════════════════════════════════════════ + +def _make_channel_combo(registry: DeviceRegistry, + include_derived: bool = False, + processor: Optional[SignalProcessor] = None) -> QComboBox: + cb = QComboBox() + for dev in registry.all_instances(): + for ch in dev.info.channels: + cb.addItem(f"{dev.info.device_id} / {ch.channel_id} ({ch.name})", + userData=(dev.info.device_id, ch.channel_id)) + if include_derived and processor: + for dc in processor.get_derived(): + cb.addItem(f"[derived] {dc.channel_id} ({dc.name})", + userData=("derived", dc.channel_id)) + return cb + + +# ══════════════════════════════════════════════════════════════════════════════ +# Filter stage row +# ══════════════════════════════════════════════════════════════════════════════ + +class FilterRow(QFrame): + removed = pyqtSignal(object) + changed = pyqtSignal() + + _PARAM_SPECS = { + "moving_average": [("window", "int", 10)], + "median": [("window", "int", 5)], + "low_pass": [("alpha", "float", 0.1)], + "high_pass": [("alpha", "float", 0.9)], + "scale_offset": [("scale", "float", 1.0), ("offset", "float", 0.0)], + "derivative": [], + "integral": [], + } + + def __init__(self, filt: FilterBase): + super().__init__() + self.filt = filt + self.setObjectName("traceRow") + self._build() + + def _build(self): + lay = QHBoxLayout(self) + lay.setContentsMargins(6, 4, 6, 4) + lay.setSpacing(8) + + type_lbl = QLabel(self.filt.name.replace("_", " ").title()) + type_lbl.setObjectName("traceSource") + type_lbl.setMinimumWidth(110) + lay.addWidget(type_lbl) + + self._param_widgets = {} + for pname, ptype, default in self._PARAM_SPECS.get(self.filt.name, []): + lbl = QLabel(f"{pname}:") + lbl.setObjectName("pwmLabel") + lay.addWidget(lbl) + if ptype == "int": + w = QSpinBox(); w.setRange(1, 10000); w.setValue(int(self.filt.params.get(pname, default))) + w.setFixedWidth(64); w.setObjectName("traceWidthSpin") + w.valueChanged.connect(lambda v, p=pname: self._update_param(p, v)) + else: + w = QDoubleSpinBox(); w.setRange(-1e9, 1e9); w.setDecimals(4) + w.setValue(float(self.filt.params.get(pname, default))) + w.setFixedWidth(80); w.setObjectName("traceWidthSpin") + w.valueChanged.connect(lambda v, p=pname: self._update_param(p, v)) + lay.addWidget(w) + self._param_widgets[pname] = w + + lay.addStretch() + rm = QToolButton(); rm.setText("✕"); rm.setObjectName("traceRemoveBtn") + rm.setFixedSize(22, 22); rm.clicked.connect(lambda: self.removed.emit(self)) + lay.addWidget(rm) + + def _update_param(self, name: str, value): + self.filt.params[name] = value + # Re-instantiate filter with new params + cls = FILTER_CLASSES.get(self.filt.name) + if cls: + new_filt = cls(**self.filt.params) + self.filt.__dict__.update(new_filt.__dict__) + self.changed.emit() + + +# ══════════════════════════════════════════════════════════════════════════════ +# Filter pipeline editor for one channel +# ══════════════════════════════════════════════════════════════════════════════ + +class PipelineEditor(QWidget): + pipeline_changed = pyqtSignal() + + def __init__(self, pipeline: ChannelPipeline, processor: SignalProcessor): + super().__init__() + self.pipeline = pipeline + self.processor = processor + self._rows: List[FilterRow] = [] + self._build() + + def _build(self): + root = QVBoxLayout(self); root.setContentsMargins(0, 0, 0, 6) + root.setSpacing(4) + + # Enable toggle + top = QHBoxLayout() + self._en_chk = QCheckBox("Pipeline enabled") + self._en_chk.setChecked(self.pipeline.enabled) + self._en_chk.toggled.connect(self._on_enable) + top.addWidget(self._en_chk) + top.addStretch() + root.addLayout(top) + + # Filter rows + self._rows_widget = QWidget() + self._rows_lay = QVBoxLayout(self._rows_widget) + self._rows_lay.setContentsMargins(0, 0, 0, 0); self._rows_lay.setSpacing(3) + for f in self.pipeline.filters: + self._add_row(f) + root.addWidget(self._rows_widget) + + # Add filter bar + add_bar = QHBoxLayout() + self._filter_cb = QComboBox() + self._filter_cb.addItems([k.replace("_"," ").title() for k in FILTER_CLASSES]) + self._filter_cb.setObjectName("channelPickerCb") + add_bar.addWidget(self._filter_cb, 1) + add_btn = QPushButton("+ Add Filter") + add_btn.setObjectName("addTraceBtn") + add_btn.clicked.connect(self._on_add_filter) + add_bar.addWidget(add_btn) + root.addLayout(add_bar) + + def _add_row(self, filt: FilterBase): + row = FilterRow(filt) + row.removed.connect(self._remove_row) + row.changed.connect(self._push) + self._rows.append(row) + self._rows_lay.addWidget(row) + + def _remove_row(self, row: FilterRow): + self.pipeline.filters.remove(row.filt) + self._rows.remove(row) + self._rows_lay.removeWidget(row); row.deleteLater() + self._push() + + def _on_add_filter(self): + key = list(FILTER_CLASSES.keys())[self._filter_cb.currentIndex()] + filt = FILTER_CLASSES[key]() + self.pipeline.filters.append(filt) + self._add_row(filt); self._push() + + def _on_enable(self, v: bool): + self.pipeline.enabled = v; self._push() + + def _push(self): + self.processor.set_pipeline(self.pipeline) + self.pipeline_changed.emit() + + +# ══════════════════════════════════════════════════════════════════════════════ +# Derived channel editor row +# ══════════════════════════════════════════════════════════════════════════════ + +class DerivedEditor(QFrame): + removed = pyqtSignal(object) + changed = pyqtSignal(object) # DerivedChannel + + _BUILTIN_KINDS = ["velocity", "acceleration", "power", "rms", "difference", "sum"] + _SCRIPT_KINDS = ["expression", "function", "custom_script"] + _ALL_KINDS = _BUILTIN_KINDS + _SCRIPT_KINDS + + _KIND_HELP = { + "velocity": "1 source: displacement channel → dy/dt", + "acceleration": "1 source: displacement channel → d²y/dt²", + "power": "2 sources: voltage, current → V×I", + "rms": "1 source → rolling RMS (set window in params)", + "difference": "2 sources → source[0] − source[1]", + "sum": "N sources → Σ sources", + "expression": 'Single line. x = list of source values, t = time.\nExample: x[0] * 2 + x[1]', + "function": "def compute(x, t):\n # x = list of source values\n return x[0] * 2", + "custom_script": "Full script. Must define:\ndef compute(x, t):\n ...\n return value", + } + + def __init__(self, dc: DerivedChannel, registry: DeviceRegistry, + processor: SignalProcessor, color_idx: int = 0): + super().__init__() + self.dc = dc + self.registry = registry + self.processor = processor + self._color_idx = color_idx + self.setObjectName("plotBlock") + self._build() + + def _build(self): + outer = QVBoxLayout(self); outer.setContentsMargins(0, 0, 0, 0); outer.setSpacing(0) + + # ── Header ───────────────────────────────────────────────────── + hdr = QWidget(); hdr.setObjectName("plotBlockHeader"); hdr.setFixedHeight(32) + hl = QHBoxLayout(hdr); hl.setContentsMargins(8, 0, 6, 0) + + self._name_edit = QLineEdit(self.dc.name) + self._name_edit.setObjectName("plotBlockTitle") + self._name_edit.textChanged.connect(lambda t: setattr(self.dc, "name", t)) + hl.addWidget(self._name_edit, 1) + + self._en_chk = QCheckBox() + self._en_chk.setChecked(self.dc.enabled) + self._en_chk.setToolTip("Enable/disable") + self._en_chk.toggled.connect(self._on_enable) + hl.addWidget(self._en_chk) + + rm = QToolButton(); rm.setText("✕"); rm.setObjectName("plotRemoveBtn") + rm.setFixedSize(22, 22); rm.clicked.connect(lambda: self.removed.emit(self)) + hl.addWidget(rm) + outer.addWidget(hdr) + + # ── Body ─────────────────────────────────────────────────────── + body = QWidget(); body.setObjectName("plotBlockSettings") + bl = QVBoxLayout(body); bl.setContentsMargins(10, 8, 10, 10); bl.setSpacing(6) + + # Meta row: id, unit, color + meta = QHBoxLayout() + meta.addWidget(QLabel("ID:")) + self._id_edit = QLineEdit(self.dc.channel_id) + self._id_edit.setObjectName("traceLabel"); self._id_edit.setFixedWidth(90) + self._id_edit.textChanged.connect(lambda t: setattr(self.dc, "channel_id", t)) + meta.addWidget(self._id_edit) + meta.addWidget(QLabel("Unit:")) + self._unit_edit = QLineEdit(self.dc.unit) + self._unit_edit.setObjectName("traceLabel"); self._unit_edit.setFixedWidth(60) + self._unit_edit.textChanged.connect(lambda t: setattr(self.dc, "unit", t)) + meta.addWidget(self._unit_edit) + meta.addStretch() + bl.addLayout(meta) + + # Kind selector + help + kind_row = QHBoxLayout() + kind_row.addWidget(QLabel("Kind:")) + self._kind_cb = QComboBox(); self._kind_cb.setObjectName("channelPickerCb") + self._kind_cb.addItems(self._ALL_KINDS) + self._kind_cb.setCurrentText(self.dc.kind) + self._kind_cb.currentTextChanged.connect(self._on_kind_changed) + kind_row.addWidget(self._kind_cb) + kind_row.addStretch() + bl.addLayout(kind_row) + + self._help_lbl = QLabel(self._KIND_HELP.get(self.dc.kind, "")) + self._help_lbl.setObjectName("traceSource") + self._help_lbl.setWordWrap(True) + bl.addWidget(self._help_lbl) + + # Sources + src_grp = QGroupBox("Sources"); src_grp.setObjectName("cfgGlobalBar") + src_lay = QVBoxLayout(src_grp); src_lay.setContentsMargins(6,4,6,4); src_lay.setSpacing(4) + self._src_rows: List[QHBoxLayout] = [] + self._src_combos: List[QComboBox] = [] + self._src_container = QWidget() + self._src_lay = QVBoxLayout(self._src_container) + self._src_lay.setContentsMargins(0,0,0,0); self._src_lay.setSpacing(3) + for src in self.dc.sources: + self._add_source_row(src) + add_src_btn = QPushButton("+ Add Source") + add_src_btn.setObjectName("addTraceBtn") + add_src_btn.clicked.connect(self._add_source_row) + src_lay.addWidget(self._src_container) + src_lay.addWidget(add_src_btn) + bl.addWidget(src_grp) + + # Expression / Script editor (shown for script kinds only) + self._script_grp = QGroupBox("Code") + script_lay = QVBoxLayout(self._script_grp); script_lay.setContentsMargins(6,4,6,4) + self._code_edit = QTextEdit() + self._code_edit.setObjectName("codeEditor") + self._code_edit.setPlaceholderText("Enter expression or function body…") + self._code_edit.setMinimumHeight(80) + self._code_edit.setMaximumHeight(160) + if self.dc.kind == "expression": + self._code_edit.setPlainText(self.dc.expression) + else: + self._code_edit.setPlainText(self.dc.script) + script_lay.addWidget(self._code_edit) + bl.addWidget(self._script_grp) + + # Compile / Apply + apply_row = QHBoxLayout() + self._status_lbl = QLabel("") + self._status_lbl.setObjectName("traceSource") + apply_row.addWidget(self._status_lbl, 1) + apply_btn = QPushButton("✓ Compile & Apply") + apply_btn.setObjectName("applyButton") + apply_btn.clicked.connect(self._apply) + apply_row.addWidget(apply_btn) + bl.addLayout(apply_row) + + outer.addWidget(body) + self._update_script_visibility() + + def _add_source_row(self, src: Tuple[str, str] = None): + row = QHBoxLayout() + cb = _make_channel_combo(self.registry) + cb.setObjectName("channelPickerCb") + if src: + for i in range(cb.count()): + if cb.itemData(i) == src: + cb.setCurrentIndex(i); break + rm = QToolButton(); rm.setText("✕"); rm.setObjectName("traceRemoveBtn") + rm.setFixedSize(22, 22) + rm.clicked.connect(lambda: self._remove_source_row(row, cb)) + row.addWidget(cb, 1); row.addWidget(rm) + self._src_lay.addLayout(row) + self._src_combos.append(cb) + self._src_rows.append(row) + + def _remove_source_row(self, row, cb): + if cb in self._src_combos: + self._src_combos.remove(cb) + if row in self._src_rows: + self._src_rows.remove(row) + while row.count(): + item = row.takeAt(0) + if item.widget(): item.widget().deleteLater() + + def _on_kind_changed(self, kind: str): + self.dc.kind = kind + self._help_lbl.setText(self._KIND_HELP.get(kind, "")) + self._update_script_visibility() + + def _update_script_visibility(self): + show = self.dc.kind in self._SCRIPT_KINDS + self._script_grp.setVisible(show) + + def _on_enable(self, v: bool): + self.dc.enabled = v + self._apply() + + def _apply(self): + # Collect sources from combos + self.dc.sources = [cb.currentData() for cb in self._src_combos + if cb.currentData() is not None] + # Collect code + if self.dc.kind == "expression": + self.dc.expression = self._code_edit.toPlainText().strip() + elif self.dc.kind in ("function", "custom_script"): + self.dc.script = self._code_edit.toPlainText() + + err = self.processor.add_derived(self.dc) + if err: + self._status_lbl.setText(f"⚠ {err[:80]}") + self._status_lbl.setStyleSheet("color:#ef4444;") + else: + self._status_lbl.setText("✓ Applied") + self._status_lbl.setStyleSheet("color:#22c55e;") + self.changed.emit(self.dc) + + +# ══════════════════════════════════════════════════════════════════════════════ +# Channel visibility tab +# ══════════════════════════════════════════════════════════════════════════════ + +class ChannelVisibilityTab(QWidget): + visibility_changed = pyqtSignal() + + def __init__(self, registry: DeviceRegistry, processor: SignalProcessor): + super().__init__() + self.registry = registry + self.processor = processor + self._checks = {} # (dev_id, ch_id) -> QCheckBox + self._build() + + def _build(self): + scroll = QScrollArea(); scroll.setWidgetResizable(True) + scroll.setObjectName("deviceScroll") + container = QWidget() + lay = QVBoxLayout(container); lay.setContentsMargins(10,10,10,10); lay.setSpacing(6) + + for dev in self.registry.all_instances(): + grp = QGroupBox(f"{dev.info.icon} {dev.info.name} [{dev.info.device_id}]") + grp_lay = QVBoxLayout(grp); grp_lay.setSpacing(3) + for ch in dev.info.channels: + row = QHBoxLayout() + chk = QCheckBox(f"{ch.channel_id} — {ch.name}") + chk.setChecked(ch.enabled) + chk.setStyleSheet(f"color:{ch.color};") + unit_lbl = QLabel(ch.unit); unit_lbl.setObjectName("traceSource") + chk.toggled.connect(lambda v, c=ch: setattr(c, "enabled", v) or self.visibility_changed.emit()) + row.addWidget(chk, 1); row.addWidget(unit_lbl) + grp_lay.addLayout(row) + self._checks[(dev.info.device_id, ch.channel_id)] = chk + lay.addWidget(grp) + lay.addStretch() + scroll.setWidget(container) + root = QVBoxLayout(self); root.setContentsMargins(0,0,0,0) + root.addWidget(scroll) + + +# ══════════════════════════════════════════════════════════════════════════════ +# SignalBuilderWindow — main floating window +# ══════════════════════════════════════════════════════════════════════════════ + +class SignalBuilderWindow(QWidget): + """ + Floating window with three tabs: + Filters | Derived Channels | Channel Visibility + """ + + pipeline_changed = pyqtSignal() + derived_changed = pyqtSignal() + visibility_changed = pyqtSignal() + closed = pyqtSignal() + + def __init__(self, registry: DeviceRegistry, + processor: SignalProcessor, parent=None): + super().__init__(parent, Qt.WindowType.Window | Qt.WindowType.Tool) + self.registry = registry + self.processor = processor + self._pipeline_editors: dict = {} + self._derived_editors: List[DerivedEditor] = [] + self._color_idx = 0 + + self.setWindowTitle("Signal Builder") + self.setMinimumSize(640, 520) + self.resize(720, 620) + self._build() + + def _build(self): + root = QVBoxLayout(self); root.setContentsMargins(0,0,0,0); root.setSpacing(0) + + # Title bar + tb = QWidget(); tb.setObjectName("devWindowTitleBar"); tb.setFixedHeight(40) + tbl = QHBoxLayout(tb); tbl.setContentsMargins(12,0,12,0) + QLabel_t = QLabel("⚗ SIGNAL BUILDER"); QLabel_t.setObjectName("devWindowTitle") + tbl.addWidget(QLabel_t) + root.addWidget(tb) + + tabs = QTabWidget(); tabs.setObjectName("signalBuilderTabs") + root.addWidget(tabs, 1) + + # ── Tab 1: Filters ──────────────────────────────────────────── + tabs.addTab(self._build_filters_tab(), " Filters ") + + # ── Tab 2: Derived Channels ─────────────────────────────────── + tabs.addTab(self._build_derived_tab(), " Derived ") + + # ── Tab 3: Channel Visibility ───────────────────────────────── + vis_tab = ChannelVisibilityTab(self.registry, self.processor) + vis_tab.visibility_changed.connect(self.visibility_changed) + tabs.addTab(vis_tab, " Channels ") + + # ── Filters tab ─────────────────────────────────────────────────────── + + def _build_filters_tab(self): + w = QWidget() + lay = QVBoxLayout(w); lay.setContentsMargins(0,0,0,0); lay.setSpacing(0) + + # Channel selector + top = QWidget(); top.setObjectName("cfgGlobalBar") + tl = QHBoxLayout(top); tl.setContentsMargins(10,6,10,6) + tl.addWidget(QLabel("Channel:")) + self._filter_ch_cb = _make_channel_combo(self.registry) + self._filter_ch_cb.setObjectName("channelPickerCb") + self._filter_ch_cb.currentIndexChanged.connect(self._on_filter_channel_changed) + tl.addWidget(self._filter_ch_cb, 1) + lay.addWidget(top) + + div = QFrame(); div.setFrameShape(QFrame.Shape.HLine); div.setObjectName("devWindowDivider") + lay.addWidget(div) + + # Pipeline editor area + scroll = QScrollArea(); scroll.setWidgetResizable(True); scroll.setObjectName("deviceScroll") + self._pipeline_container = QWidget() + self._pipeline_lay = QVBoxLayout(self._pipeline_container) + self._pipeline_lay.setContentsMargins(10,10,10,10); self._pipeline_lay.setSpacing(0) + self._pipeline_lay.addStretch() + scroll.setWidget(self._pipeline_container) + lay.addWidget(scroll, 1) + + # Trigger initial load + self._on_filter_channel_changed(0) + return w + + def _on_filter_channel_changed(self, _idx: int): + data = self._filter_ch_cb.currentData() + if not data: return + dev_id, ch_id = data + + # Clear old editor + while self._pipeline_lay.count() > 1: + item = self._pipeline_lay.takeAt(0) + if item.widget(): item.widget().deleteLater() + + # Get or create pipeline + pipeline = self.processor.get_pipeline(dev_id, ch_id) + if pipeline is None: + pipeline = ChannelPipeline(device_id=dev_id, channel_id=ch_id) + self.processor.set_pipeline(pipeline) + + editor = PipelineEditor(pipeline, self.processor) + editor.pipeline_changed.connect(self.pipeline_changed) + self._pipeline_lay.insertWidget(0, editor) + + # ── Derived tab ─────────────────────────────────────────────────────── + + def _build_derived_tab(self): + w = QWidget() + lay = QVBoxLayout(w); lay.setContentsMargins(0,0,0,0); lay.setSpacing(0) + + # Add derived button bar + top = QWidget(); top.setObjectName("cfgGlobalBar") + tl = QHBoxLayout(top); tl.setContentsMargins(10,6,10,6) + tl.addWidget(QLabel("New derived channel:")) + self._new_kind_cb = QComboBox(); self._new_kind_cb.setObjectName("channelPickerCb") + self._new_kind_cb.addItems(DerivedEditor._ALL_KINDS) + tl.addWidget(self._new_kind_cb) + add_btn = QPushButton("+ Create") + add_btn.setObjectName("addDeviceButton") + add_btn.clicked.connect(self._add_derived) + tl.addWidget(add_btn) + lay.addWidget(top) + + div = QFrame(); div.setFrameShape(QFrame.Shape.HLine); div.setObjectName("devWindowDivider") + lay.addWidget(div) + + # Derived editors scroll area + scroll = QScrollArea(); scroll.setWidgetResizable(True); scroll.setObjectName("deviceScroll") + self._derived_container = QWidget() + self._derived_lay = QVBoxLayout(self._derived_container) + self._derived_lay.setContentsMargins(10,10,10,10); self._derived_lay.setSpacing(10) + self._derived_lay.addStretch() + scroll.setWidget(self._derived_container) + lay.addWidget(scroll, 1) + + # Load existing derived channels + for dc in self.processor.get_derived(): + self._insert_derived_editor(dc) + + return w + + def _add_derived(self): + kind = self._new_kind_cb.currentText() + color = _DERIVED_COLORS[self._color_idx % len(_DERIVED_COLORS)] + self._color_idx += 1 + dc = DerivedChannel( + channel_id=f"derived_{len(self._derived_editors)}", + name=f"Derived {len(self._derived_editors) + 1}", + kind=kind, color=color, + ) + self._insert_derived_editor(dc) + + def _insert_derived_editor(self, dc: DerivedChannel): + editor = DerivedEditor(dc, self.registry, self.processor, self._color_idx) + editor.removed.connect(self._remove_derived_editor) + editor.changed.connect(lambda _: self.derived_changed.emit()) + self._derived_editors.append(editor) + self._derived_lay.insertWidget(self._derived_lay.count() - 1, editor) + + def _remove_derived_editor(self, editor: DerivedEditor): + self.processor.remove_derived(editor.dc.channel_id) + self._derived_editors.remove(editor) + self._derived_lay.removeWidget(editor); editor.deleteLater() + self.derived_changed.emit() + + def closeEvent(self, event: QCloseEvent): + self.closed.emit(); event.accept() diff --git a/ui/strip_chart.py b/ui/strip_chart.py index afc7cf7..7bc2a18 100644 --- a/ui/strip_chart.py +++ b/ui/strip_chart.py @@ -1,186 +1,217 @@ """ -ui/strip_chart.py +ui/strip_chart.py — Config-driven live chart. -Live scrolling strip chart. One pyqtgraph plot per device, -all time-axes linked. Per-channel colored traces with legend. +Consumes LayoutConfig from plot_window. +Supports stacked, side-by-side, and grid layouts. +X axis can be time or any channel (e.g. stress vs strain). """ import numpy as np -from typing import Dict +from typing import Dict, List, Optional, Tuple +from collections import deque from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QLabel, - QDoubleSpinBox, QPushButton, QSizePolicy, QComboBox, + QDoubleSpinBox, QPushButton, QSizePolicy, ) from PyQt6.QtCore import Qt, pyqtSlot +from PyQt6.QtGui import QFont try: import pyqtgraph as pg - pg.setConfigOptions(antialias=True, background="#0b0e13", foreground="#475569") _HAS_PG = True except ImportError: _HAS_PG = False +_THEME_DARK = {"bg": "#0b0e13", "fg": "#475569", "grid_alpha": 0.12, "legend_brush": "#161d2e", "legend_pen": "#2a3558", "tick_color": "#64748b"} +_THEME_LIGHT = {"bg": "#ffffff", "fg": "#334155", "grid_alpha": 0.18, "legend_brush": "#f8fafc", "legend_pen": "#cbd5e1", "tick_color": "#64748b"} +_current_theme = _THEME_DARK + +def set_chart_theme(theme: str): + global _current_theme + _current_theme = _THEME_DARK if theme == "dark" else _THEME_LIGHT + if _HAS_PG: + pg.setConfigOptions(antialias=True, + background=_current_theme["bg"], + foreground=_current_theme["fg"]) + +# Apply dark theme as default +if _HAS_PG: + pg.setConfigOptions(antialias=True, background=_THEME_DARK["bg"], foreground=_THEME_DARK["fg"]) + from devices.device_registry import DeviceRegistry from core.acquisition import AcquisitionEngine +from core.signal_processor import SignalProcessor +from ui.windows.plot_window import LayoutConfig, PaneSpec, TraceSpec, build_default_layout + +_STYLES = { + "solid": Qt.PenStyle.SolidLine, + "dash": Qt.PenStyle.DashLine, + "dot": Qt.PenStyle.DotLine, +} class StripChartWidget(QWidget): - def __init__(self, engine: AcquisitionEngine, registry: DeviceRegistry): + def __init__(self, engine: AcquisitionEngine, registry: DeviceRegistry, + processor: SignalProcessor): 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.engine = engine + self.registry = registry + self.processor = processor + self._paused = False + self._cfg: Optional[LayoutConfig] = None + # (dev_id, ch_id) → [{curve, plot, spec}] + self._curves: Dict[Tuple, List[dict]] = {} self._build() - self.refresh() - - # ── Build ──────────────────────────────────────────────────────────── + self.apply_layout(build_default_layout(registry, processor)) def _build(self): - layout = QVBoxLayout(self) - layout.setContentsMargins(6, 6, 6, 4) - layout.setSpacing(4) - - # Control bar + lay = QVBoxLayout(self); lay.setContentsMargins(6,6,6,4); lay.setSpacing(4) 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) - + self._win = QDoubleSpinBox(); self._win.setRange(1,3600) + self._win.setSuffix(" s"); self._win.setValue(30.0) + self._win.valueChanged.connect(lambda v: self._cfg and setattr(self._cfg,"time_window_s",v)) + ctrl.addWidget(self._win); ctrl.addStretch() + self._pause = QPushButton("⏸ Pause"); self._pause.setCheckable(True) + self._pause.setObjectName("pauseButton"); self._pause.toggled.connect(self._on_pause) + ctrl.addWidget(self._pause); lay.addLayout(ctrl) if _HAS_PG: self._gw = pg.GraphicsLayoutWidget() - self._gw.setSizePolicy( - QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding - ) - layout.addWidget(self._gw) + self._gw.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + lay.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: + lay.addWidget(QLabel("⚠ pip install pyqtgraph", + alignment=Qt.AlignmentFlag.AlignCenter)) + + def apply_layout(self, cfg: LayoutConfig): + self._cfg = cfg + self._win.setValue(cfg.time_window_s) + if not _HAS_PG: return + self._gw.clear(); self._curves.clear() + if not cfg.panes: return + + mf = QFont("IBM Plex Mono", 8) + n = len(cfg.panes) + + # Determine row/col for each pane + if cfg.layout_mode == "sidebyside": + positions = [(0, c) for c in range(n)] + elif cfg.layout_mode == "grid": + cols = max(1, cfg.grid_cols) + positions = [(i // cols, i % cols) for i in range(n)] + else: # stacked + positions = [(r, 0) for r in range(n)] + + ref_plot = None + for idx, (spec, (row, col)) in enumerate(zip(cfg.panes, positions)): + plot = self._gw.addPlot(row=row, col=col) + plot.setLabel("left", spec.y_label or spec.title) + plot.setLabel("bottom", spec.x_label or "Elapsed (s)") + plot.showGrid(x=spec.grid, y=spec.grid, alpha=0.12) + plot.getAxis("left").setStyle(tickFont=mf) + plot.getAxis("bottom").setStyle(tickFont=mf) + + # Hide bottom axis labels for all but bottom row in stacked + if cfg.layout_mode == "stacked" and idx < n - 1: plot.getAxis("bottom").setStyle(showValues=False) plot.getAxis("bottom").setHeight(0) - else: - plot.setLabel("bottom", "Elapsed (s)") - if ref_plot is not None: + if cfg.link_x and ref_plot and spec.x_source == "time": 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 ──────────────────────────────────────────────────────────── + if spec.x_source == "time": + ref_plot = plot + + if cfg.show_legend: + t = _current_theme + plot.addLegend(offset=(5,5), labelTextColor=t["tick_color"], + brush=pg.mkBrush(t["legend_brush"]), + pen=pg.mkPen(t["legend_pen"])) + + if not spec.y_auto: + plot.setYRange(spec.y_min, spec.y_max) + else: + plot.enableAutoRange(axis="y") + + # Set column stretch (relative weight) + self._gw.ci.layout.setColumnStretchFactor(col, spec.weight) + if cfg.layout_mode == "stacked": + self._gw.ci.layout.setRowStretchFactor(row, spec.weight) + + for tr in spec.traces: + if not tr.visible: continue + pen = pg.mkPen(color=tr.color, width=tr.width, + style=_STYLES.get(tr.style, Qt.PenStyle.SolidLine)) + curve = plot.plot([], [], pen=pen, name=tr.label or tr.channel_id) + key = (tr.device_id, tr.channel_id) + self._curves.setdefault(key, []).append( + {"curve": curve, "plot": plot, "spec": spec, "pane": spec}) + + def set_theme(self, theme: str): + """Hot-swap pyqtgraph colours when the user changes theme.""" + set_chart_theme(theme) + if _HAS_PG and self._gw is not None: + self._gw.setBackground(_current_theme["bg"]) + # Rebuild plots so legend/grid colours update + self.apply_layout(self._cfg or build_default_layout(self.registry, self.processor)) + + def refresh(self): + self.apply_layout(self._cfg or build_default_layout(self.registry, self.processor)) @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") + def on_new_data(self, device_id: str, channel_id: str, ts: float, val: float): + if self._paused or not _HAS_PG or not self._cfg: return + key = (device_id, channel_id) + entries = self._curves.get(key) + if not entries: return + window = self._cfg.time_window_s + + # Get Y data + if device_id == "derived": + bufs = self.processor.get_derived_buffer(channel_id) + if bufs is None or len(bufs[0]) < 2: return + ts_l = list(bufs[0]); vs_l = list(bufs[1]) + else: + buf = self.engine.get_buffer(device_id, channel_id) + if buf is None or len(buf) < 2: return + ts_l, vs_l = buf.window(window) + if not ts_l: return + + for e in entries: + spec = e["pane"] + # Determine X axis data + if spec.x_source == "time": + cutoff = ts_l[-1] - window if ts_l else 0 + idx = next((i for i,t in enumerate(ts_l) if t >= cutoff), 0) + x_arr = np.array(ts_l[idx:], dtype=np.float64) + y_arr = np.array(vs_l[idx:], dtype=np.float64) + e["curve"].setData(x_arr, y_arr) + if x_arr.size: + e["plot"].setXRange(x_arr[-1]-window, x_arr[-1], padding=0) + else: + # X = another channel's processed values + parts = spec.x_source.split("/") + if len(parts) == 2: + x_dev, x_ch = parts + if x_dev == "derived": + xbufs = self.processor.get_derived_buffer(x_ch) + if xbufs and len(xbufs[0]) >= 2: + x_arr = np.array(list(xbufs[1]), dtype=np.float64) + y_arr = np.array(vs_l, dtype=np.float64) + mn = min(len(x_arr), len(y_arr)) + e["curve"].setData(x_arr[-mn:], y_arr[-mn:]) + else: + xbuf = self.engine.get_buffer(x_dev, x_ch) + if xbuf and len(xbuf) >= 2: + _, xvs = xbuf.all() + x_arr = np.array(xvs, dtype=np.float64) + y_arr = np.array(vs_l, dtype=np.float64) + mn = min(len(x_arr), len(y_arr)) + e["curve"].setData(x_arr[-mn:], y_arr[-mn:]) + + if spec.y_auto: + e["plot"].enableAutoRange(axis="y") + + def _on_pause(self, c: bool): + self._paused = c + self._pause.setText("▶ Resume" if c else "⏸ Pause") diff --git a/ui/style.qss b/ui/style.qss index 60415fb..3761c68 100644 --- a/ui/style.qss +++ b/ui/style.qss @@ -376,3 +376,548 @@ QPushButton:pressed { background-color: #172554; } QPushButton:disabled { color: #334155; border-color: #1c2540; } QLabel { color: #94a3b8; } + +/* ══════════════════════════════════════════════════════════════ + Devices Window + ══════════════════════════════════════════════════════════════ */ + +QWidget#devWindowTitleBar { + background-color: #0f1521; + border-bottom: 1px solid #2a3558; +} +QLabel#devWindowTitle { + font-family: "IBM Plex Mono", monospace; + font-size: 11px; + font-weight: 700; + letter-spacing: 2px; + color: #64748b; +} +QFrame#devWindowDivider { + color: #2a3558; + max-height: 1px; +} + +/* ══════════════════════════════════════════════════════════════ + Control Widgets — shared frame + ══════════════════════════════════════════════════════════════ */ + +QFrame#controlWidget { + background-color: #161d2e; + border: 1px solid #2a3558; + border-radius: 6px; +} +QFrame#controlWidget:hover { + border-color: #3b82f6; +} + +QWidget#controlWidgetHeader { + background-color: #0f1521; + border-radius: 5px 5px 0 0; + border-bottom: 1px solid #2a3558; +} +QLabel#controlWidgetIcon { + font-size: 13px; + color: #3b82f6; + padding-right: 4px; +} +QLabel#controlWidgetTitle { + font-family: "IBM Plex Mono", monospace; + font-size: 10px; + font-weight: 700; + letter-spacing: 1.5px; + color: #64748b; +} +QWidget#controlWidgetBody { + background-color: #161d2e; + border-radius: 0 0 5px 5px; +} + +/* ── On/Off Switch ─────────────────────────────────────────── */ + +QPushButton#switchBtn, +QPushButton#switchBtnOff { + background-color: #1c2540; + color: #475569; + border: 2px solid #2a3558; + border-radius: 6px; + font-family: "IBM Plex Mono", monospace; + font-size: 18px; + font-weight: 800; + letter-spacing: 4px; + min-height: 52px; +} +QPushButton#switchBtnOn { + background-color: #14532d; + color: #dcfce7; + border: 2px solid #22c55e; + border-radius: 6px; + font-family: "IBM Plex Mono", monospace; + font-size: 18px; + font-weight: 800; + letter-spacing: 4px; + min-height: 52px; +} +QPushButton#switchBtnOff:hover { border-color: #64748b; color: #94a3b8; } +QPushButton#switchBtnOn:hover { background-color: #166534; } + +QLabel#switchIndicatorOff { + font-size: 18px; + color: #334155; +} +QLabel#switchIndicatorOn { + font-size: 18px; + color: #22c55e; +} +QLabel#switchStateLbl { + font-family: "IBM Plex Mono", monospace; + font-size: 10px; + font-weight: 700; + letter-spacing: 1px; + color: #475569; +} + +/* ── Motor Control ─────────────────────────────────────────── */ + +QLabel#motorSpeedLbl { + font-family: "IBM Plex Mono", monospace; + font-size: 28px; + font-weight: 700; + color: #00d4ff; +} +QLabel#motorUnitLbl { + font-family: "IBM Plex Mono", monospace; + font-size: 11px; + color: #475569; + padding-top: 12px; +} +QLabel#motorPctLbl { + font-family: "IBM Plex Mono", monospace; + font-size: 11px; + color: #475569; +} + +QSlider#motorSlider::groove:horizontal, +QSlider#pwmSlider::groove:horizontal { + background: #1c2540; + height: 6px; + border-radius: 3px; +} +QSlider#motorSlider::handle:horizontal, +QSlider#pwmSlider::handle:horizontal { + background: #3b82f6; + border: 2px solid #1d4ed8; + width: 16px; + height: 16px; + margin: -5px 0; + border-radius: 8px; +} +QSlider#motorSlider::sub-page:horizontal { + background: #1d4ed8; + border-radius: 3px; +} +QSlider#pwmSlider::sub-page:horizontal { + background: #7c3aed; + border-radius: 3px; +} +QSlider#motorSlider::handle:horizontal:hover, +QSlider#pwmSlider::handle:horizontal:hover { + background: #60a5fa; +} + +QPushButton#motorDirBtn { + background-color: #1c2540; + color: #94a3b8; + border: 1px solid #2a3558; + border-radius: 4px; + padding: 5px 10px; + font-family: "IBM Plex Mono", monospace; + font-size: 11px; +} +QPushButton#motorDirBtnRev { + background-color: #2d1b69; + color: #c4b5fd; + border: 1px solid #7c3aed; + border-radius: 4px; + padding: 5px 10px; + font-family: "IBM Plex Mono", monospace; + font-size: 11px; +} + +QPushButton#motorRunBtnOff { + background-color: #1c2540; + color: #64748b; + border: 1px solid #2a3558; + border-radius: 4px; + padding: 5px 12px; + font-family: "IBM Plex Mono", monospace; + font-weight: 700; + letter-spacing: 0.5px; +} +QPushButton#motorRunBtnOn { + background-color: #166534; + color: #dcfce7; + border: 1px solid #22c55e; + border-radius: 4px; + padding: 5px 12px; + font-family: "IBM Plex Mono", monospace; + font-weight: 700; + letter-spacing: 0.5px; +} +QPushButton#motorRunBtnOff:hover { border-color: #64748b; color: #94a3b8; } +QPushButton#motorRunBtnOn:hover { background-color: #15803d; } + +/* ── Setpoint Control ──────────────────────────────────────── */ + +QLabel#spLabel { + font-family: "IBM Plex Mono", monospace; + font-size: 10px; + font-weight: 700; + color: #3b82f6; + letter-spacing: 0.5px; +} +QLabel#pvLabel { + font-family: "IBM Plex Mono", monospace; + font-size: 10px; + font-weight: 700; + color: #22c55e; + letter-spacing: 0.5px; +} +QLabel#errLabel { + font-family: "IBM Plex Mono", monospace; + font-size: 10px; + font-weight: 700; + color: #f59e0b; + letter-spacing: 0.5px; +} +QLabel#pvValue, QLabel#errValue { + font-family: "IBM Plex Mono", monospace; + font-size: 12px; + color: #94a3b8; +} +QDoubleSpinBox#spSpinbox { + font-family: "IBM Plex Mono", monospace; + font-size: 13px; + font-weight: 600; + color: #e2e8f0; + background-color: #0b0e13; + border: 1px solid #3b82f6; + border-radius: 3px; + padding: 3px 6px; +} +QPushButton#spStepBtn { + background-color: #1e3a5f; + color: #93c5fd; + border: 1px solid #3b82f6; + border-radius: 3px; + font-size: 16px; + font-weight: 700; + padding: 0; +} +QPushButton#spStepBtn:hover { + background-color: #1d4ed8; + color: #eff6ff; +} + +/* ── PWM Control ───────────────────────────────────────────── */ + +QLabel#pwmLabel { + font-family: "IBM Plex Mono", monospace; + font-size: 10px; + color: #64748b; + letter-spacing: 0.5px; +} +QLabel#pwmValue { + font-family: "IBM Plex Mono", monospace; + font-size: 13px; + font-weight: 700; + color: #c4b5fd; +} +QSpinBox#pwmFreqSpin { + font-family: "IBM Plex Mono", monospace; + font-size: 12px; + background-color: #0b0e13; + border: 1px solid #2a3558; + border-radius: 3px; + color: #e2e8f0; + padding: 3px 6px; +} + +/* ══════════════════════════════════════════════════════════════ + Plot Config Button (toolbar) + ══════════════════════════════════════════════════════════════ */ + +QPushButton#plotCfgButton { + background-color: #1a1f35; + color: #818cf8; + border: 1px solid #4338ca; + border-radius: 4px; + padding: 5px 14px; + font-weight: 600; +} +QPushButton#plotCfgButton:hover { + background-color: #312e81; + color: #e0e7ff; + border-color: #6366f1; +} +QPushButton#plotCfgButton:checked { + background-color: #312e81; + color: #e0e7ff; + border-color: #818cf8; +} + +/* ══════════════════════════════════════════════════════════════ + Plot Config Window + ══════════════════════════════════════════════════════════════ */ + +QWidget#cfgTopBar { + background-color: #0b0e13; + border-bottom: 1px solid #2a3558; +} +QLabel#cfgTopBarTitle { + font-family: "IBM Plex Mono", monospace; + font-size: 12px; + font-weight: 700; + letter-spacing: 1.5px; + color: #818cf8; +} +QCheckBox#cfgLiveChk { + color: #94a3b8; + font-size: 11px; + spacing: 5px; +} + +QWidget#cfgGlobalBar { + background-color: #0f1521; + border-bottom: 1px solid #1c2540; +} +QDoubleSpinBox#cfgGlobalSpin { + font-family: "IBM Plex Mono", monospace; + font-size: 12px; + background: #0b0e13; + border: 1px solid #2a3558; + border-radius: 3px; + color: #e2e8f0; + padding: 3px 6px; +} + +QWidget#cfgBottomBar { + background-color: #0b0e13; + border-top: 1px solid #2a3558; +} + +/* ── Plot Block ──────────────────────────────────────────────── */ + +QFrame#plotBlock { + background-color: #111620; + border: 1px solid #2a3558; + border-radius: 6px; +} +QFrame#plotBlock:hover { + border-color: #4338ca; +} + +QWidget#plotBlockHeader { + background-color: #0d1117; + border-radius: 5px 5px 0 0; + border-bottom: 1px solid #2a3558; +} +QLineEdit#plotBlockTitle { + font-family: "IBM Plex Mono", monospace; + font-size: 12px; + font-weight: 700; + color: #c7d2fe; + background: transparent; + border: none; + border-bottom: 1px solid transparent; + padding: 2px 4px; +} +QLineEdit#plotBlockTitle:focus { + border-bottom: 1px solid #4338ca; + color: #e0e7ff; +} + +QToolButton#plotMoveBtn { + background-color: transparent; + color: #475569; + border: none; + font-size: 11px; + border-radius: 3px; +} +QToolButton#plotMoveBtn:hover { + background-color: #1c2540; + color: #94a3b8; +} +QToolButton#plotRemoveBtn { + background-color: transparent; + color: #475569; + border: none; + font-size: 12px; + border-radius: 3px; +} +QToolButton#plotRemoveBtn:hover { + background-color: #450a0a; + color: #ef4444; +} + +QWidget#plotBlockSettings { + background-color: #111620; + border-bottom: 1px solid #1c2540; +} +QLineEdit#plotYLabelEdit { + font-family: "IBM Plex Mono", monospace; + font-size: 11px; + background: #0b0e13; + border: 1px solid #2a3558; + border-radius: 3px; + color: #94a3b8; + padding: 3px 6px; + max-width: 140px; +} + +QWidget#plotBlockTraces { + background-color: #111620; + border-radius: 0 0 5px 5px; +} + +/* ── Trace Row ───────────────────────────────────────────────── */ + +QFrame#traceRow { + background-color: #161d2e; + border: 1px solid #1c2540; + border-radius: 4px; +} +QFrame#traceRow:hover { + border-color: #2a3558; + background-color: #1c2540; +} + +QLabel#traceSource { + font-family: "IBM Plex Mono", monospace; + font-size: 10px; + color: #475569; +} +QLineEdit#traceLabel { + font-family: "IBM Plex Mono", monospace; + font-size: 11px; + background: #0b0e13; + border: 1px solid #1c2540; + border-radius: 3px; + color: #94a3b8; + padding: 2px 6px; +} +QLineEdit#traceLabel:focus { + border-color: #3b82f6; + color: #e2e8f0; +} +QComboBox#traceStyleCb { + font-family: "IBM Plex Mono", monospace; + font-size: 10px; + background: #0b0e13; + border: 1px solid #1c2540; + border-radius: 3px; + color: #64748b; + padding: 2px 4px; +} +QDoubleSpinBox#traceWidthSpin { + font-family: "IBM Plex Mono", monospace; + font-size: 10px; + background: #0b0e13; + border: 1px solid #1c2540; + border-radius: 3px; + color: #64748b; + padding: 2px 4px; +} +QToolButton#traceRemoveBtn { + background: transparent; + color: #334155; + border: none; + font-size: 12px; + border-radius: 3px; +} +QToolButton#traceRemoveBtn:hover { + background-color: #450a0a; + color: #ef4444; +} + +/* ── Add trace button / channel picker ──────────────────────── */ + +QPushButton#addTraceBtn { + background-color: #1c2540; + color: #64748b; + border: 1px dashed #2a3558; + border-radius: 4px; + padding: 4px 12px; + font-size: 11px; +} +QPushButton#addTraceBtn:hover { + background-color: #1e3a5f; + color: #93c5fd; + border-color: #3b82f6; + border-style: solid; +} +QComboBox#channelPickerCb { + font-family: "IBM Plex Mono", monospace; + font-size: 11px; + background: #0b0e13; + border: 1px solid #2a3558; + border-radius: 3px; + color: #64748b; + padding: 4px 8px; +} +QComboBox#channelPickerCb:focus { border-color: #3b82f6; } + +/* ══════════════════════════════════════════════════════════════ + Signal Builder window / code editor + ══════════════════════════════════════════════════════════════ */ + +QTabWidget#signalBuilderTabs::pane { + background-color: #0b0e13; + border: 1px solid #2a3558; +} +QTabWidget#signalBuilderTabs QTabBar::tab { + background-color: #0b0e13; + color: #475569; + border: 1px solid #1c2540; + padding: 6px 18px; + font-family: "IBM Plex Mono", monospace; + font-size: 11px; + letter-spacing: 0.5px; +} +QTabWidget#signalBuilderTabs QTabBar::tab:selected { + background-color: #161d2e; + color: #c7d2fe; + border-bottom: 2px solid #818cf8; +} + +QTextEdit#codeEditor { + font-family: "IBM Plex Mono", monospace; + font-size: 12px; + background-color: #060810; + color: #e2e8f0; + border: 1px solid #2a3558; + border-radius: 4px; + padding: 6px; + selection-background-color: #1e3a5f; +} +QTextEdit#codeEditor:focus { + border-color: #818cf8; +} + +/* ── Control panel header with device button ────────────────── */ +QWidget#controlPanelHeader { + background-color: #0f1521; + border-bottom: 1px solid #2a3558; +} +QPushButton#devicesSmallBtn { + background-color: transparent; + color: #475569; + border: 1px solid #2a3558; + border-radius: 3px; + font-size: 13px; + padding: 0; +} +QPushButton#devicesSmallBtn:hover { + background-color: #1c2540; + color: #94a3b8; + border-color: #3b82f6; +} diff --git a/ui/style_dark.qss b/ui/style_dark.qss new file mode 100644 index 0000000..9c20ab2 --- /dev/null +++ b/ui/style_dark.qss @@ -0,0 +1,468 @@ +/* LabDAQ — Industrial Dark Theme + Palette: + bg-base #0b0e13 bg-panel #111620 bg-card #161d2e + bg-raised #1c2540 border #2a3558 + accent #3b82f6 cyan #00d4ff green #22c55e + text #e2e8f0 muted #8b9dc3 dim #64748b + danger #ef4444 warning #f59e0b +*/ + +/* ── Global ─────────────────────────────────────────────────────── */ +* { + font-family: "IBM Plex Sans", "Segoe UI", Tahoma, sans-serif; + font-size: 13px; + 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: #8b9dc3; + 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; } + +QPushButton#toolbarSectionBtn { + background-color: #161d2e; + color: #8b9dc3; + border: 1px solid #2a3558; + border-radius: 4px; + padding: 5px 14px; + font-weight: 600; +} +QPushButton#toolbarSectionBtn:hover { background-color: #1c2540; color: #c7d2fe; border-color: #3b82f6; } +QPushButton#toolbarSectionBtn:checked { background-color: #1e3a5f; color: #93c5fd; border-color: #3b82f6; } + +QFrame#toolbarSep { color: #2a3558; max-width: 1px; margin: 4px 4px; } + +QLabel#timeLabel { + font-family: "IBM Plex Mono", monospace; + font-size: 16px; + font-weight: 700; + color: #00d4ff; + letter-spacing: 2px; + padding-right: 8px; +} + +/* ── Panel headers ───────────────────────────────────────────────── */ +QLabel#panelHeader { + background-color: #0f1521; + color: #8b9dc3; + font-family: "IBM Plex Mono", monospace; + font-size: 11px; + font-weight: 700; + letter-spacing: 2px; + padding: 6px 10px; + border-bottom: 1px solid #2a3558; +} + +/* ── Device Cards ────────────────────────────────────────────────── */ +QScrollArea#deviceScroll { + background-color: #0b0e13; + border: none; +} + +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: 11px; color: #8b9dc3; } +QLabel#deviceChannelCount { font-size: 11px; color: #3b82f6; } + +QPushButton#configButton { + background-color: #1c2540; + color: #8b9dc3; + border: 1px solid #2a3558; + border-radius: 3px; + padding: 3px 10px; + font-size: 12px; +} +QPushButton#configButton:hover { background-color: #1e3a5f; color: #93c5fd; border-color: #3b82f6; } + +/* ── GroupBox — the main fix ─────────────────────────────────────── */ +QGroupBox { + border: 1px solid #2a3558; + border-radius: 5px; + margin-top: 28px; + padding-top: 8px; + background-color: #111620; +} +QGroupBox::title { + subcontrol-origin: margin; + subcontrol-position: top left; + left: 12px; + top: -14px; + background-color: #1c2540; + color: #c7d2fe; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.5px; + padding: 3px 10px; + border: 1px solid #3b82f6; + border-radius: 4px; +} + +/* ── Form inputs ─────────────────────────────────────────────────── */ +QLineEdit, QDoubleSpinBox, QSpinBox, QComboBox { + background-color: #0b0e13; + border: 1px solid #2a3558; + border-radius: 3px; + padding: 5px 8px; + color: #e2e8f0; + font-family: "IBM Plex Mono", monospace; + font-size: 12px; + min-height: 24px; +} +QLineEdit:focus, QDoubleSpinBox:focus, QSpinBox:focus, QComboBox:focus { + border-color: #3b82f6; + background-color: #0f1521; +} +QComboBox::drop-down { border: none; width: 20px; } +QComboBox QAbstractItemView { + background-color: #161d2e; + border: 1px solid #3b82f6; + selection-background-color: #1e3a5f; + color: #e2e8f0; +} + +QCheckBox { color: #c7d2fe; spacing: 7px; font-size: 13px; } +QCheckBox::indicator { + width: 15px; height: 15px; + border: 1px solid #2a3558; + border-radius: 3px; + background-color: #0b0e13; +} +QCheckBox::indicator:checked { background-color: #3b82f6; border-color: #3b82f6; } + +QLabel { color: #c7d2fe; } + +/* ── Port list ───────────────────────────────────────────────────── */ +QListWidget#portList { + background-color: #0b0e13; + border: 1px solid #2a3558; + border-radius: 4px; + color: #e2e8f0; + font-family: "IBM Plex Mono", monospace; + font-size: 13px; + outline: none; + min-height: 160px; +} +QListWidget#portList::item { + padding: 8px 10px; + border-bottom: 1px solid #1c2540; + color: #e2e8f0; + min-height: 28px; +} +QListWidget#portList::item:hover { background-color: #1c2540; color: #93c5fd; } +QListWidget#portList::item:selected { background-color: #1e3a5f; color: #e0f2fe; border-left: 3px solid #3b82f6; } + +/* ── Channel table ───────────────────────────────────────────────── */ +QTableWidget#channelTable { + background-color: #0b0e13; + gridline-color: #1c2540; + border: 1px solid #2a3558; + alternate-background-color: #111620; + color: #e2e8f0; + font-size: 12px; +} +QHeaderView::section { + background-color: #161d2e; + color: #8b9dc3; + border: none; + border-bottom: 1px solid #2a3558; + padding: 5px 8px; + font-family: "IBM Plex Mono", monospace; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.5px; +} + +/* ── Floating window headers ─────────────────────────────────────── */ +QWidget#devWindowTitleBar { + background-color: #0f1521; + border-bottom: 1px solid #2a3558; +} +QLabel#devWindowTitle { + font-family: "IBM Plex Mono", monospace; + font-size: 13px; + font-weight: 700; + letter-spacing: 1.5px; + color: #c7d2fe; +} +QFrame#devWindowDivider { color: #2a3558; max-height: 1px; } + +QWidget#cfgTopBar { background-color: #0b0e13; border-bottom: 1px solid #2a3558; } +QLabel#cfgTopBarTitle { + font-family: "IBM Plex Mono", monospace; + font-size: 13px; + font-weight: 700; + letter-spacing: 1.5px; + color: #818cf8; +} + +QWidget#cfgGlobalBar { background-color: #0f1521; border-bottom: 1px solid #1c2540; } +QWidget#cfgBottomBar { background-color: #0b0e13; border-top: 1px solid #2a3558; } + +QDoubleSpinBox#cfgGlobalSpin { + font-family: "IBM Plex Mono", monospace; + font-size: 12px; + background: #0b0e13; + border: 1px solid #2a3558; + border-radius: 3px; + color: #e2e8f0; + padding: 3px 6px; +} + +/* ── Plot blocks ─────────────────────────────────────────────────── */ +QFrame#plotBlock { background-color: #111620; border: 1px solid #2a3558; border-radius: 6px; } +QFrame#plotBlock:hover { border-color: #4338ca; } + +QWidget#plotBlockHeader { + background-color: #0d1117; + border-radius: 5px 5px 0 0; + border-bottom: 1px solid #2a3558; +} +QLineEdit#plotBlockTitle { + font-family: "IBM Plex Mono", monospace; + font-size: 13px; + font-weight: 700; + color: #c7d2fe; + background: transparent; + border: none; + border-bottom: 1px solid transparent; + padding: 2px 4px; +} +QLineEdit#plotBlockTitle:focus { border-bottom: 1px solid #4338ca; color: #e0e7ff; } + +QToolButton#plotMoveBtn { background: transparent; color: #64748b; border: none; font-size: 12px; border-radius: 3px; } +QToolButton#plotMoveBtn:hover { background-color: #1c2540; color: #94a3b8; } +QToolButton#plotRemoveBtn { background: transparent; color: #475569; border: none; font-size: 13px; border-radius: 3px; } +QToolButton#plotRemoveBtn:hover { background-color: #450a0a; color: #ef4444; } + +QWidget#plotBlockSettings { background-color: #111620; border-bottom: 1px solid #1c2540; } +QWidget#plotBlockTraces { background-color: #111620; border-radius: 0 0 5px 5px; } + +/* ── Trace rows ─────────────────────────────────────────────────── */ +QFrame#traceRow { background-color: #161d2e; border: 1px solid #1c2540; border-radius: 4px; } +QFrame#traceRow:hover { border-color: #2a3558; background-color: #1c2540; } + +QLabel#traceSource { + font-family: "IBM Plex Mono", monospace; + font-size: 12px; + color: #8b9dc3; +} +QLineEdit#traceLabel { + font-family: "IBM Plex Mono", monospace; + font-size: 12px; + background: #0b0e13; + border: 1px solid #1c2540; + border-radius: 3px; + color: #c7d2fe; + padding: 2px 6px; +} +QLineEdit#traceLabel:focus { border-color: #3b82f6; color: #e2e8f0; } +QLineEdit#plotYLabelEdit { font-family: "IBM Plex Mono", monospace; font-size: 12px; background: #0b0e13; border: 1px solid #2a3558; border-radius: 3px; color: #8b9dc3; padding: 3px 6px; max-width: 140px; } + +QComboBox#traceStyleCb, QDoubleSpinBox#traceWidthSpin { + font-family: "IBM Plex Mono", monospace; font-size: 11px; + background: #0b0e13; border: 1px solid #1c2540; border-radius: 3px; color: #8b9dc3; padding: 2px 4px; +} +QToolButton#traceRemoveBtn { background: transparent; color: #334155; border: none; font-size: 13px; border-radius: 3px; } +QToolButton#traceRemoveBtn:hover { background-color: #450a0a; color: #ef4444; } + +QPushButton#addTraceBtn { + background-color: #1c2540; color: #8b9dc3; + border: 1px dashed #2a3558; border-radius: 4px; padding: 5px 12px; font-size: 12px; +} +QPushButton#addTraceBtn:hover { background-color: #1e3a5f; color: #93c5fd; border-color: #3b82f6; border-style: solid; } + +QComboBox#channelPickerCb { font-family: "IBM Plex Mono", monospace; font-size: 12px; background: #0b0e13; border: 1px solid #2a3558; border-radius: 3px; color: #8b9dc3; padding: 4px 8px; } +QComboBox#channelPickerCb:focus { border-color: #3b82f6; } + +/* ── Tabs ────────────────────────────────────────────────────────── */ +QTabWidget::pane { background-color: #111620; border: 1px solid #2a3558; border-radius: 4px; } +QTabBar::tab { + background-color: #0b0e13; color: #8b9dc3; + border: 1px solid #2a3558; padding: 7px 18px; margin-right: 2px; + font-size: 12px; +} +QTabBar::tab:selected { background-color: #161d2e; color: #e2e8f0; border-bottom: 2px solid #3b82f6; } +QTabBar::tab:hover { background-color: #111620; color: #c7d2fe; } + +QTabWidget#signalBuilderTabs QTabBar::tab { + font-family: "IBM Plex Mono", monospace; font-size: 12px; letter-spacing: 0.5px; +} +QTabWidget#signalBuilderTabs QTabBar::tab:selected { color: #c7d2fe; border-bottom: 2px solid #818cf8; } + +/* ── Code editor ─────────────────────────────────────────────────── */ +QTextEdit#codeEditor { + font-family: "IBM Plex Mono", monospace; + font-size: 13px; + background-color: #060810; + color: #e2e8f0; + border: 1px solid #2a3558; + border-radius: 4px; + padding: 6px; + selection-background-color: #1e3a5f; +} +QTextEdit#codeEditor:focus { border-color: #818cf8; } + +/* ── Scrollbars ───────────────────────────────────────────────────── */ +QScrollBar:vertical { background: #0b0e13; width: 8px; margin: 0; } +QScrollBar::handle:vertical { background: #2a3558; border-radius: 4px; min-height: 20px; } +QScrollBar::handle:vertical:hover { background: #3b82f6; } +QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; } +QScrollBar:horizontal { height: 8px; background: #0b0e13; } +QScrollBar::handle:horizontal { background: #2a3558; border-radius: 4px; min-width: 20px; } +QScrollBar::handle:horizontal:hover { background: #3b82f6; } +QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal { width: 0; } + +/* ── Splitter ─────────────────────────────────────────────────────── */ +QSplitter::handle { background-color: #2a3558; } +QSplitter::handle:hover { background-color: #3b82f6; } + +/* ── Status bar ───────────────────────────────────────────────────── */ +QStatusBar { + background-color: #0b0e13; border-top: 1px solid #2a3558; + color: #8b9dc3; font-family: "IBM Plex Mono", monospace; font-size: 12px; +} + +/* ── Default button ───────────────────────────────────────────────── */ +QPushButton { + background-color: #1c2540; color: #8b9dc3; + border: 1px solid #2a3558; border-radius: 4px; padding: 5px 12px; font-size: 12px; +} +QPushButton:hover { background-color: #1e3a5f; color: #e2e8f0; } +QPushButton:pressed { background-color: #172554; } +QPushButton:disabled { color: #334155; border-color: #1c2540; } + +QPushButton#applyButton { + background-color: #1e3a5f; color: #93c5fd; + border: 1px solid #3b82f6; border-radius: 4px; + padding: 6px 20px; font-weight: 600; font-size: 13px; +} +QPushButton#applyButton:hover { background-color: #1d4ed8; color: #eff6ff; } + +/* ── Control widgets ─────────────────────────────────────────────── */ +QFrame#controlWidget { background-color: #161d2e; border: 1px solid #2a3558; border-radius: 6px; } +QFrame#controlWidget:hover { border-color: #3b82f6; } +QWidget#controlWidgetHeader { background-color: #0f1521; border-radius: 5px 5px 0 0; border-bottom: 1px solid #2a3558; } +QLabel#controlWidgetIcon { font-size: 14px; color: #3b82f6; padding-right: 4px; } +QLabel#controlWidgetTitle { font-family: "IBM Plex Mono", monospace; font-size: 11px; font-weight: 700; letter-spacing: 1.5px; color: #8b9dc3; } +QWidget#controlWidgetBody { background-color: #161d2e; border-radius: 0 0 5px 5px; } + +QLabel#motorSpeedLbl { font-family: "IBM Plex Mono", monospace; font-size: 28px; font-weight: 700; color: #00d4ff; } +QLabel#motorUnitLbl { font-family: "IBM Plex Mono", monospace; font-size: 12px; color: #64748b; padding-top: 12px; } +QLabel#motorPctLbl { font-family: "IBM Plex Mono", monospace; font-size: 12px; color: #64748b; } + +QSlider#motorSlider::groove:horizontal, QSlider#pwmSlider::groove:horizontal { background: #1c2540; height: 6px; border-radius: 3px; } +QSlider#motorSlider::handle:horizontal, QSlider#pwmSlider::handle:horizontal { background: #3b82f6; border: 2px solid #1d4ed8; width: 16px; height: 16px; margin: -5px 0; border-radius: 8px; } +QSlider#motorSlider::sub-page:horizontal { background: #1d4ed8; border-radius: 3px; } +QSlider#pwmSlider::sub-page:horizontal { background: #7c3aed; border-radius: 3px; } + +QPushButton#motorDirBtn { background-color: #1c2540; color: #8b9dc3; border: 1px solid #2a3558; border-radius: 4px; padding: 5px 10px; font-family: "IBM Plex Mono", monospace; font-size: 12px; } +QPushButton#motorDirBtnRev { background-color: #2d1b69; color: #c4b5fd; border: 1px solid #7c3aed; border-radius: 4px; padding: 5px 10px; font-family: "IBM Plex Mono", monospace; font-size: 12px; } +QPushButton#motorRunBtnOff { background-color: #1c2540; color: #64748b; border: 1px solid #2a3558; border-radius: 4px; padding: 5px 12px; font-family: "IBM Plex Mono", monospace; font-weight: 700; } +QPushButton#motorRunBtnOn { background-color: #166534; color: #dcfce7; border: 1px solid #22c55e; border-radius: 4px; padding: 5px 12px; font-family: "IBM Plex Mono", monospace; font-weight: 700; } + +QPushButton#switchBtnOff, QPushButton#switchBtn { background-color: #1c2540; color: #475569; border: 2px solid #2a3558; border-radius: 6px; font-family: "IBM Plex Mono", monospace; font-size: 18px; font-weight: 800; letter-spacing: 4px; min-height: 52px; } +QPushButton#switchBtnOn { background-color: #14532d; color: #dcfce7; border: 2px solid #22c55e; border-radius: 6px; font-family: "IBM Plex Mono", monospace; font-size: 18px; font-weight: 800; letter-spacing: 4px; min-height: 52px; } +QLabel#switchIndicatorOff { font-size: 18px; color: #334155; } +QLabel#switchIndicatorOn { font-size: 18px; color: #22c55e; } +QLabel#switchStateLbl { font-family: "IBM Plex Mono", monospace; font-size: 10px; font-weight: 700; letter-spacing: 1px; color: #475569; } + +QLabel#spLabel { font-family: "IBM Plex Mono", monospace; font-size: 11px; font-weight: 700; color: #3b82f6; letter-spacing: 0.5px; } +QLabel#pvLabel { font-family: "IBM Plex Mono", monospace; font-size: 11px; font-weight: 700; color: #22c55e; letter-spacing: 0.5px; } +QLabel#errLabel { font-family: "IBM Plex Mono", monospace; font-size: 11px; font-weight: 700; color: #f59e0b; letter-spacing: 0.5px; } +QLabel#pvValue, QLabel#errValue { font-family: "IBM Plex Mono", monospace; font-size: 12px; color: #8b9dc3; } +QDoubleSpinBox#spSpinbox { font-family: "IBM Plex Mono", monospace; font-size: 13px; font-weight: 600; color: #e2e8f0; background-color: #0b0e13; border: 1px solid #3b82f6; border-radius: 3px; padding: 3px 6px; } +QPushButton#spStepBtn { background-color: #1e3a5f; color: #93c5fd; border: 1px solid #3b82f6; border-radius: 3px; font-size: 16px; font-weight: 700; padding: 0; } +QPushButton#spStepBtn:hover { background-color: #1d4ed8; color: #eff6ff; } + +QLabel#pwmLabel { font-family: "IBM Plex Mono", monospace; font-size: 11px; color: #8b9dc3; } +QLabel#pwmValue { font-family: "IBM Plex Mono", monospace; font-size: 13px; font-weight: 700; color: #c4b5fd; } +QSpinBox#pwmFreqSpin { font-family: "IBM Plex Mono", monospace; font-size: 12px; background-color: #0b0e13; border: 1px solid #2a3558; border-radius: 3px; color: #e2e8f0; padding: 3px 6px; } +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; } + +/* ── Devices window ──────────────────────────────────────────────── */ +QWidget#devWindowTitleBar { background-color: #0f1521; border-bottom: 1px solid #2a3558; } +QFrame#devWindowDivider { color: #2a3558; max-height: 1px; } +QWidget#controlPanelHeader { background-color: #0f1521; border-bottom: 1px solid #2a3558; } +QPushButton#devicesSmallBtn { background-color: transparent; color: #64748b; border: 1px solid #2a3558; border-radius: 3px; font-size: 13px; padding: 0; } +QPushButton#devicesSmallBtn:hover { background-color: #1c2540; color: #94a3b8; border-color: #3b82f6; } + +/* ── Plot Config buttons ─────────────────────────────────────────── */ +QPushButton#plotCfgButton { background-color: #1a1f35; color: #818cf8; border: 1px solid #4338ca; border-radius: 4px; padding: 5px 14px; font-weight: 600; } +QPushButton#plotCfgButton:hover { background-color: #312e81; color: #e0e7ff; border-color: #6366f1; } +QPushButton#plotCfgButton:checked { background-color: #312e81; color: #e0e7ff; border-color: #818cf8; } + +/* ── Signals window tabs ─────────────────────────────────────────── */ +QCheckBox#cfgLiveChk { color: #8b9dc3; font-size: 12px; spacing: 5px; } +QRadioButton#cfgLiveChk { color: #8b9dc3; font-size: 12px; spacing: 5px; } + +/* ── Settings window form labels ────────────────────────────────── */ +QFormLayout QLabel { color: #c7d2fe; font-size: 12px; } + +/* ── FormLayout labels — ensure visibility inside GroupBoxes ────── */ +QGroupBox QLabel { + color: #c7d2fe; + font-size: 12px; +} +QGroupBox QFormLayout QLabel { + color: #8b9dc3; + font-size: 12px; + min-width: 90px; +} + +/* ── Hint / info labels ──────────────────────────────────────────── */ +QLabel#traceSource { + color: #8b9dc3; + font-family: "IBM Plex Mono", monospace; + font-size: 12px; +} diff --git a/ui/style_light.qss b/ui/style_light.qss new file mode 100644 index 0000000..86b35c7 --- /dev/null +++ b/ui/style_light.qss @@ -0,0 +1,69 @@ +/* LabDAQ — Light Theme */ +* { font-family:"IBM Plex Sans","Segoe UI",Tahoma,sans-serif; font-size:12px; color:#1e293b; } +QMainWindow,QDialog { background:#f8fafc; } +QToolBar#mainToolbar { background:#ffffff; border-bottom:1px solid #e2e8f0; padding:4px 8px; spacing:4px; } +QPushButton#runButton { background:#166534; color:#dcfce7; border:1px solid #22c55e; border-radius:4px; padding:5px 16px; font-family:"IBM Plex Mono",monospace; font-weight:600; min-width:90px; } +QPushButton#runButton:checked { background:#991b1b; border-color:#ef4444; color:#fee2e2; } +QPushButton#logButton { background:#f1f5f9; color:#64748b; border:1px solid #cbd5e1; border-radius:4px; padding:5px 14px; font-family:"IBM Plex Mono",monospace; min-width:80px; } +QPushButton#logButton:enabled { color:#1e293b; border-color:#3b82f6; } +QPushButton#logButton:checked { background:#fef2f2; border-color:#ef4444; color:#991b1b; } +QPushButton#toolbarSectionBtn { background:#f1f5f9; color:#475569; border:1px solid #cbd5e1; border-radius:4px; padding:5px 14px; font-weight:600; } +QPushButton#toolbarSectionBtn:hover { background:#e2e8f0; color:#1e293b; } +QPushButton#toolbarSectionBtn:checked { background:#dbeafe; color:#1d4ed8; border-color:#3b82f6; } +QLabel#timeLabel { font-family:"IBM Plex Mono",monospace; font-size:16px; font-weight:700; color:#3b82f6; letter-spacing:2px; padding-right:8px; } +QLabel#panelHeader { background:#f1f5f9; color:#64748b; font-family:"IBM Plex Mono",monospace; font-size:10px; font-weight:700; letter-spacing:2px; padding:6px 8px; border-bottom:1px solid #e2e8f0; } +QFrame#deviceCard { background:#ffffff; border:1px solid #e2e8f0; border-radius:6px; } +QFrame#deviceCard:hover { border-color:#3b82f6; background:#f0f9ff; } +QLabel#deviceName { font-size:13px; font-weight:600; color:#1e293b; } +QLabel#deviceSub { font-family:"IBM Plex Mono",monospace; font-size:10px; color:#94a3b8; } +QLabel#deviceChannelCount { font-size:11px; color:#3b82f6; } +QPushButton#configButton { background:#f1f5f9; color:#64748b; border:1px solid #cbd5e1; border-radius:3px; padding:3px 10px; font-size:11px; } +QPushButton#configButton:hover { background:#dbeafe; color:#1d4ed8; border-color:#3b82f6; } +QScrollArea#deviceScroll { background:#f8fafc; border:none; } +QFrame#traceRow { background:#ffffff; border:1px solid #e2e8f0; border-radius:4px; } +QFrame#plotBlock { background:#f8fafc; border:1px solid #e2e8f0; border-radius:6px; } +QWidget#plotBlockHeader { background:#f1f5f9; border-bottom:1px solid #e2e8f0; border-radius:5px 5px 0 0; } +QLineEdit#plotBlockTitle { font-family:"IBM Plex Mono",monospace; font-size:12px; font-weight:700; color:#334155; background:transparent; border:none; } +QWidget#plotBlockSettings { background:#f8fafc; border-bottom:1px solid #e2e8f0; } +QWidget#plotBlockTraces { background:#f8fafc; border-radius:0 0 5px 5px; } +QPushButton#addTraceBtn { background:#f0f9ff; color:#3b82f6; border:1px dashed #93c5fd; border-radius:4px; padding:4px 12px; } +QPushButton#addTraceBtn:hover { background:#dbeafe; border-style:solid; } +QPushButton#applyButton { background:#1d4ed8; color:#eff6ff; border:1px solid #3b82f6; border-radius:4px; padding:6px 20px; font-weight:600; } +QPushButton#applyButton:hover { background:#1e40af; } +QComboBox,QLineEdit,QDoubleSpinBox,QSpinBox { background:#ffffff; border:1px solid #cbd5e1; border-radius:3px; padding:4px 8px; color:#1e293b; font-family:"IBM Plex Mono",monospace; min-height:22px; } +QComboBox:focus,QLineEdit:focus,QDoubleSpinBox:focus,QSpinBox:focus { border-color:#3b82f6; } +QCheckBox { color:#475569; spacing:6px; } +QCheckBox::indicator { width:14px;height:14px;border:1px solid #cbd5e1;border-radius:3px;background:#ffffff; } +QCheckBox::indicator:checked { background:#3b82f6;border-color:#3b82f6; } +QTabWidget::pane { background:#ffffff; border:1px solid #e2e8f0; border-radius:4px; } +QTabBar::tab { background:#f1f5f9; color:#64748b; border:1px solid #e2e8f0; padding:6px 16px; margin-right:2px; } +QTabBar::tab:selected { background:#ffffff; color:#1e293b; border-bottom:2px solid #3b82f6; } +QGroupBox { border:1px solid #e2e8f0; border-radius:4px; margin-top:22px; padding-top:6px; color:#475569; font-size:11px; font-weight:700; letter-spacing:0.8px; } +QGroupBox::title { subcontrol-origin:margin; subcontrol-position:top left; left:12px; top:-11px; background:#f8fafc; padding:1px 8px; border-radius:3px; color:#475569; font-size:11px; font-weight:700; } +QScrollBar:vertical { background:#f1f5f9; width:6px; margin:0; } +QScrollBar::handle:vertical { background:#cbd5e1; border-radius:3px; min-height:20px; } +QScrollBar::handle:vertical:hover { background:#3b82f6; } +QScrollBar::add-line:vertical,QScrollBar::sub-line:vertical { height:0; } +QSplitter::handle { background:#e2e8f0; } +QSplitter::handle:hover { background:#3b82f6; } +QStatusBar { background:#ffffff; border-top:1px solid #e2e8f0; color:#64748b; font-family:"IBM Plex Mono",monospace; font-size:11px; } +QPushButton { background:#f1f5f9; color:#475569; border:1px solid #cbd5e1; border-radius:4px; padding:5px 12px; } +QPushButton:hover { background:#e2e8f0; color:#1e293b; } +QTextEdit#codeEditor { font-family:"IBM Plex Mono",monospace; font-size:12px; background:#1e293b; color:#e2e8f0; border:1px solid #cbd5e1; border-radius:4px; padding:6px; } +QLabel#traceSource { font-family:"IBM Plex Mono",monospace; font-size:10px; color:#64748b; } +QWidget#devWindowTitleBar { background:#f1f5f9; border-bottom:1px solid #e2e8f0; } +QLabel#devWindowTitle { font-family:"IBM Plex Mono",monospace; font-size:11px; font-weight:700; letter-spacing:2px; color:#475569; } +QFrame#devWindowDivider { color:#e2e8f0; max-height:1px; } +QWidget#cfgGlobalBar { background:#f8fafc; border-bottom:1px solid #e2e8f0; } +QWidget#cfgBottomBar { background:#f8fafc; border-top:1px solid #e2e8f0; } +QTableWidget#channelTable { background:#ffffff; gridline-color:#f1f5f9; border:1px solid #e2e8f0; alternate-background-color:#f8fafc; } +QHeaderView::section { background:#f1f5f9; color:#475569; border:none; border-bottom:1px solid #e2e8f0; padding:4px 8px; font-weight:600; } + +/* Port scanner list — light theme */ +QListWidget#portList { + background:#ffffff; border:1px solid #cbd5e1; border-radius:4px; + color:#1e293b; font-family:"IBM Plex Mono",monospace; font-size:12px; +} +QListWidget#portList::item { padding:5px 8px; border-bottom:1px solid #f1f5f9; } +QListWidget#portList::item:hover { background:#f0f9ff; color:#1d4ed8; } +QListWidget#portList::item:selected { background:#dbeafe; color:#1d4ed8; border-left:3px solid #3b82f6; } diff --git a/ui/windows/__init__.py b/ui/windows/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/ui/windows/__init__.py diff --git a/ui/windows/__pycache__/__init__.cpython-314.pyc b/ui/windows/__pycache__/__init__.cpython-314.pyc Binary files differnew file mode 100644 index 0000000..ea15dd5 --- /dev/null +++ b/ui/windows/__pycache__/__init__.cpython-314.pyc diff --git a/ui/windows/__pycache__/devices_window.cpython-314.pyc b/ui/windows/__pycache__/devices_window.cpython-314.pyc Binary files differnew file mode 100644 index 0000000..88763e7 --- /dev/null +++ b/ui/windows/__pycache__/devices_window.cpython-314.pyc diff --git a/ui/windows/__pycache__/plot_window.cpython-314.pyc b/ui/windows/__pycache__/plot_window.cpython-314.pyc Binary files differnew file mode 100644 index 0000000..ad53019 --- /dev/null +++ b/ui/windows/__pycache__/plot_window.cpython-314.pyc diff --git a/ui/windows/__pycache__/settings_window.cpython-314.pyc b/ui/windows/__pycache__/settings_window.cpython-314.pyc Binary files differnew file mode 100644 index 0000000..0926445 --- /dev/null +++ b/ui/windows/__pycache__/settings_window.cpython-314.pyc diff --git a/ui/windows/__pycache__/signals_window.cpython-314.pyc b/ui/windows/__pycache__/signals_window.cpython-314.pyc Binary files differnew file mode 100644 index 0000000..4194ac8 --- /dev/null +++ b/ui/windows/__pycache__/signals_window.cpython-314.pyc diff --git a/ui/windows/devices_window.py b/ui/windows/devices_window.py new file mode 100644 index 0000000..fbaf9fa --- /dev/null +++ b/ui/windows/devices_window.py @@ -0,0 +1,319 @@ +""" +ui/windows/devices_window.py + +DEVICES window — toolbar section 2. + +Tabs: + Physical Devices — add / remove / connect devices; shows status cards + Channels — per-device channel list: rename, set unit, enable/disable +""" + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, + QScrollArea, QFrame, QTabWidget, QTableWidget, QTableWidgetItem, + QHeaderView, QCheckBox, QLineEdit, QDoubleSpinBox, QComboBox, + QSizePolicy, QMessageBox, QAbstractItemView, +) +from PyQt6.QtCore import Qt, pyqtSignal, QTimer, QSize +from PyQt6.QtGui import QCloseEvent, QColor, QFont + +from devices.base_device import DeviceStatus +from devices.device_registry import DeviceRegistry +from core.acquisition import AcquisitionEngine + + +_DOT_COLOR = { + DeviceStatus.CONNECTED: "#22c55e", + DeviceStatus.SIMULATED: "#3b82f6", + DeviceStatus.DISCONNECTED: "#475569", + DeviceStatus.CONNECTING: "#f59e0b", + DeviceStatus.ERROR: "#ef4444", +} +_STATUS_TEXT = { + DeviceStatus.CONNECTED: "CONNECTED", + DeviceStatus.SIMULATED: "SIMULATED", + DeviceStatus.DISCONNECTED: "OFFLINE", + DeviceStatus.CONNECTING: "CONNECTING…", + DeviceStatus.ERROR: "ERROR", +} + + +class DeviceRow(QFrame): + configure_clicked = pyqtSignal(str) + remove_clicked = pyqtSignal(str) + + def __init__(self, device): + super().__init__() + self.device = device + self.setObjectName("deviceCard") + self._build() + + def _build(self): + lay = QHBoxLayout(self) + lay.setContentsMargins(10, 8, 10, 8) + lay.setSpacing(10) + + # Status dot + self._dot = QLabel("●") + self._dot.setFixedWidth(14) + self._set_dot_color() + lay.addWidget(self._dot) + + # Icon + name + icon = QLabel(self.device.info.icon) + icon.setObjectName("deviceIcon") + lay.addWidget(icon) + + info_col = QVBoxLayout() + info_col.setSpacing(1) + name = QLabel(self.device.info.name) + name.setObjectName("deviceName") + id_lbl = QLabel(f"{self.device.info.device_id} · {self.device.info.device_type}") + id_lbl.setObjectName("deviceSub") + info_col.addWidget(name) + info_col.addWidget(id_lbl) + lay.addLayout(info_col, 1) + + # Status + channel count + stat_col = QVBoxLayout() + stat_col.setSpacing(1) + self._status_lbl = QLabel(_STATUS_TEXT.get(self.device.status, "?")) + self._status_lbl.setObjectName("deviceSub") + self._set_status_style() + n = sum(1 for c in self.device.info.channels if c.enabled) + ch_lbl = QLabel(f"{n} ch") + ch_lbl.setObjectName("deviceChannelCount") + stat_col.addWidget(self._status_lbl) + stat_col.addWidget(ch_lbl) + lay.addLayout(stat_col) + + cfg_btn = QPushButton("Configure") + cfg_btn.setObjectName("configButton") + cfg_btn.clicked.connect(lambda: self.configure_clicked.emit(self.device.info.device_id)) + lay.addWidget(cfg_btn) + + rm_btn = QPushButton("✕") + rm_btn.setObjectName("traceRemoveBtn") + rm_btn.setFixedSize(24, 24) + rm_btn.clicked.connect(lambda: self.remove_clicked.emit(self.device.info.device_id)) + lay.addWidget(rm_btn) + + def refresh(self): + self._set_dot_color() + self._status_lbl.setText(_STATUS_TEXT.get(self.device.status, "?")) + self._set_status_style() + + def _set_dot_color(self): + c = _DOT_COLOR.get(self.device.status, "#475569") + self._dot.setStyleSheet(f"color:{c}; font-size:10px;") + + def _set_status_style(self): + c = _DOT_COLOR.get(self.device.status, "#475569") + self._status_lbl.setStyleSheet( + f"color:{c}; font-family:'IBM Plex Mono',monospace;" + f" font-size:10px; font-weight:700; letter-spacing:1px;" + ) + + +class ChannelsTab(QWidget): + """Shows all channels across all devices in an editable table.""" + + def __init__(self, registry: DeviceRegistry): + super().__init__() + self.registry = registry + self._build() + + def _build(self): + lay = QVBoxLayout(self) + lay.setContentsMargins(0, 0, 0, 0) + + self._table = QTableWidget() + self._table.setObjectName("channelTable") + self._table.setColumnCount(6) + self._table.setHorizontalHeaderLabels( + ["Device", "Channel ID", "Name", "Unit", "Min", "Max"] + ) + self._table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch) + self._table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) + self._table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + self._table.setAlternatingRowColors(True) + self._table.verticalHeader().setVisible(False) + lay.addWidget(self._table) + + def refresh(self): + self._table.setRowCount(0) + row = 0 + for dev in self.registry.all_instances(): + for ch in dev.info.channels: + self._table.insertRow(row) + + dev_item = QTableWidgetItem(f"{dev.info.icon} {dev.info.device_id}") + dev_item.setFlags(dev_item.flags() & ~Qt.ItemFlag.ItemIsEditable) + dev_item.setForeground(QColor("#64748b")) + self._table.setItem(row, 0, dev_item) + + ch_item = QTableWidgetItem(ch.channel_id) + ch_item.setFlags(ch_item.flags() & ~Qt.ItemFlag.ItemIsEditable) + ch_item.setForeground(QColor(ch.color)) + self._table.setItem(row, 1, ch_item) + + name_item = QTableWidgetItem(ch.name) + self._table.setItem(row, 2, name_item) + + unit_item = QTableWidgetItem(ch.unit) + self._table.setItem(row, 3, unit_item) + + min_item = QTableWidgetItem(str(ch.min_value)) + max_item = QTableWidgetItem(str(ch.max_value)) + self._table.setItem(row, 4, min_item) + self._table.setItem(row, 5, max_item) + + # Store references for saving + name_item.setData(Qt.ItemDataRole.UserRole, (dev.info.device_id, ch.channel_id)) + row += 1 + + self._table.cellChanged.connect(self._on_cell_changed) + + def _on_cell_changed(self, row: int, col: int): + item = self._table.item(row, 2) # name col + if item is None: + return + ref = item.data(Qt.ItemDataRole.UserRole) + if ref is None: + return + dev_id, ch_id = ref + dev = self.registry.get_instance(dev_id) + if not dev: + return + ch = dev.get_channel(ch_id) + if not ch: + return + if col == 2: + ch.name = self._table.item(row, 2).text() + elif col == 3: + ch.unit = self._table.item(row, 3).text() + elif col == 4: + try: + ch.min_value = float(self._table.item(row, 4).text()) + except ValueError: + pass + elif col == 5: + try: + ch.max_value = float(self._table.item(row, 5).text()) + except ValueError: + pass + + +class DevicesWindow(QWidget): + device_added = pyqtSignal() + device_removed = pyqtSignal(str) + device_reconfigured = pyqtSignal(str) + closed = pyqtSignal() + + def __init__(self, registry: DeviceRegistry, engine: AcquisitionEngine, parent=None): + super().__init__(parent, Qt.WindowType.Window | Qt.WindowType.Tool) + self.registry = registry + self.engine = engine + self._rows: dict = {} + + self.setWindowTitle("Devices") + self.setMinimumSize(580, 480) + self.resize(640, 560) + self._build() + self.refresh() + + self._poll = QTimer(self) + self._poll.setInterval(2000) + self._poll.timeout.connect(self._refresh_status) + self._poll.start() + + def _build(self): + root = QVBoxLayout(self) + root.setContentsMargins(0, 0, 0, 0) + root.setSpacing(0) + + # Header + hdr = QWidget(); hdr.setObjectName("devWindowTitleBar"); hdr.setFixedHeight(44) + hl = QHBoxLayout(hdr); hl.setContentsMargins(14, 0, 10, 0) + title = QLabel("DEVICES"); title.setObjectName("devWindowTitle") + hl.addWidget(title, 1) + add_btn = QPushButton("+ Add Device") + add_btn.setObjectName("addDeviceButton") + add_btn.clicked.connect(self._on_add) + hl.addWidget(add_btn) + root.addWidget(hdr) + + div = QFrame(); div.setFrameShape(QFrame.Shape.HLine) + div.setObjectName("devWindowDivider"); root.addWidget(div) + + tabs = QTabWidget(); tabs.setObjectName("signalBuilderTabs") + root.addWidget(tabs, 1) + + # Tab 1: Physical devices + dev_tab = QWidget() + dev_lay = QVBoxLayout(dev_tab); dev_lay.setContentsMargins(0,0,0,0) + scroll = QScrollArea(); scroll.setWidgetResizable(True) + scroll.setObjectName("deviceScroll") + scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self._container = QWidget() + self._inner = QVBoxLayout(self._container) + self._inner.setContentsMargins(10,10,10,10); self._inner.setSpacing(6) + self._inner.addStretch() + scroll.setWidget(self._container) + dev_lay.addWidget(scroll) + tabs.addTab(dev_tab, " Physical Devices ") + + # Tab 2: Channels + self._ch_tab = ChannelsTab(self.registry) + tabs.addTab(self._ch_tab, " Channels ") + tabs.currentChanged.connect(lambda i: self._ch_tab.refresh() if i == 1 else None) + + def refresh(self): + for row in self._rows.values(): + self._inner.removeWidget(row); row.deleteLater() + self._rows.clear() + for dev in self.registry.all_instances(): + row = DeviceRow(dev) + row.configure_clicked.connect(self._on_configure) + row.remove_clicked.connect(self._on_remove) + self._inner.insertWidget(self._inner.count() - 1, row) + self._rows[dev.info.device_id] = row + + def _refresh_status(self): + for row in self._rows.values(): + row.refresh() + + def _on_add(self): + from ui.add_device_dialog import AddDeviceDialog + 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.refresh() + self.device_added.emit() + + def _on_configure(self, device_id: str): + from ui.config_dialog import DeviceConfigDialog + dev = self.registry.get_instance(device_id) + if dev: + DeviceConfigDialog(dev, self).exec() + # Signal that device config may have changed (backend/simulate/port) + self.device_reconfigured.emit(device_id) + + def _on_remove(self, device_id: str): + reply = QMessageBox.question( + self, "Remove Device", + f"Remove '{device_id}'? This cannot be undone.", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + ) + if reply == QMessageBox.StandardButton.Yes: + self.engine.remove_device(device_id) + self.registry.remove_instance(device_id) + self.refresh() + self.device_removed.emit(device_id) + + def closeEvent(self, e: QCloseEvent): + self.closed.emit(); e.accept() diff --git a/ui/windows/plot_window.py b/ui/windows/plot_window.py new file mode 100644 index 0000000..1be590a --- /dev/null +++ b/ui/windows/plot_window.py @@ -0,0 +1,486 @@ +""" +ui/windows/plot_window.py + +PLOT window — toolbar section 4. + +Lets operators: + • Choose layout: Stacked (rows) or Side-by-Side (columns) or Grid (N×M) + • Add/remove/reorder subplot panes + • Assign any channel (physical or derived) to any pane + • Set X-axis: Time (elapsed s) OR any channel (e.g. strain → stress vs strain) + • Name axes, set Y auto/fixed range, grid, relative size weight + • Per-trace: color, label, line style, width, visibility + • Export/Import JSON layout + +Emits layout_applied(LayoutConfig) → StripChartWidget.apply_layout() +""" + +from __future__ import annotations +import json +from copy import deepcopy +from dataclasses import dataclass, field, asdict +from typing import List, Optional, Tuple + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, + QScrollArea, QFrame, QLineEdit, QDoubleSpinBox, QSpinBox, + QCheckBox, QComboBox, QColorDialog, QToolButton, QButtonGroup, + QFileDialog, QMessageBox, QRadioButton, QGroupBox, QSizePolicy, +) +from PyQt6.QtCore import Qt, pyqtSignal +from PyQt6.QtGui import QColor, QCloseEvent + +from devices.device_registry import DeviceRegistry +from core.signal_processor import SignalProcessor + + +# ══════════════════════════════════════════════════════════════════════════════ +# Data model +# ══════════════════════════════════════════════════════════════════════════════ + +@dataclass +class TraceSpec: + device_id: str + channel_id: str + label: str = "" + color: str = "#00d4ff" + width: float = 1.8 + visible: bool = True + style: str = "solid" # solid | dash | dot + +@dataclass +class PaneSpec: + title: str = "Plot" + x_source: str = "time" # "time" | "dev_id/ch_id" + y_label: str = "" + x_label: str = "Elapsed (s)" + y_auto: bool = True + y_min: float = -10.0 + y_max: float = 10.0 + grid: bool = True + weight: int = 1 # relative size + traces: List[TraceSpec] = field(default_factory=list) + +@dataclass +class LayoutConfig: + panes: List[PaneSpec] = field(default_factory=list) + layout_mode: str = "stacked" # stacked | sidebyside | grid + grid_cols: int = 2 + time_window_s: float = 30.0 + link_x: bool = True + show_legend: bool = True + + def to_json(self) -> str: + return json.dumps(asdict(self), indent=2) + + @staticmethod + def from_json(s: str) -> "LayoutConfig": + d = json.loads(s) + panes = [] + for p in d.pop("panes", []): + traces = [TraceSpec(**t) for t in p.pop("traces", [])] + panes.append(PaneSpec(**p, traces=traces)) + return LayoutConfig(**d, panes=panes) + + +def build_default_layout(registry: DeviceRegistry, + processor: SignalProcessor) -> LayoutConfig: + cfg = LayoutConfig() + for dev in registry.all_instances(): + enabled = [ch for ch in dev.info.channels if ch.enabled] + if not enabled: continue + pane = PaneSpec(title=dev.info.name, + y_label=enabled[0].unit if enabled else "") + for ch in enabled: + pane.traces.append(TraceSpec( + device_id=dev.info.device_id, channel_id=ch.channel_id, + label=ch.name, color=ch.color, + )) + if pane.traces: cfg.panes.append(pane) + for dc in processor.get_derived(): + pane = PaneSpec(title=dc.name, y_label=dc.unit) + pane.traces.append(TraceSpec( + device_id="derived", channel_id=dc.channel_id, + label=dc.name, color=dc.color, + )) + cfg.panes.append(pane) + return cfg + + +# ══════════════════════════════════════════════════════════════════════════════ +# Color swatch +# ══════════════════════════════════════════════════════════════════════════════ + +class ColorSwatch(QPushButton): + color_changed = pyqtSignal(str) + def __init__(self, color="#00d4ff"): + super().__init__(); self._c = color + self.setFixedSize(26,20); self._paint(); self.clicked.connect(self._pick) + def _paint(self): + self.setStyleSheet(f"QPushButton{{background:{self._c};border:1px solid #2a3558;" + f"border-radius:3px;}}QPushButton:hover{{border-color:#3b82f6;}}") + def _pick(self): + c = QColorDialog.getColor(QColor(self._c), self) + if c.isValid(): self._c = c.name(); self._paint(); self.color_changed.emit(self._c) + @property + def color(self): return self._c + + +# ══════════════════════════════════════════════════════════════════════════════ +# Trace row +# ══════════════════════════════════════════════════════════════════════════════ + +class TraceRow(QFrame): + removed = pyqtSignal(object) + changed = pyqtSignal() + def __init__(self, tr: TraceSpec): + super().__init__(); self.tr = tr; self.setObjectName("traceRow"); self._build() + def _build(self): + lay = QHBoxLayout(self); lay.setContentsMargins(6,3,6,3); lay.setSpacing(6) + self._vis = QCheckBox(); self._vis.setChecked(self.tr.visible) + self._vis.toggled.connect(lambda v: (setattr(self.tr,"visible",v), self.changed.emit())) + lay.addWidget(self._vis) + self._sw = ColorSwatch(self.tr.color) + self._sw.color_changed.connect(lambda c: (setattr(self.tr,"color",c), self.changed.emit())) + lay.addWidget(self._sw) + src = QLabel(f"{self.tr.device_id} / {self.tr.channel_id}") + src.setObjectName("traceSource"); src.setMinimumWidth(130); lay.addWidget(src) + self._lbl = QLineEdit(self.tr.label); self._lbl.setObjectName("traceLabel") + self._lbl.setPlaceholderText("Label…") + self._lbl.textChanged.connect(lambda t: setattr(self.tr,"label",t)) + lay.addWidget(self._lbl,1) + self._sty = QComboBox(); self._sty.setObjectName("traceStyleCb") + self._sty.addItems(["solid","dash","dot"]); self._sty.setCurrentText(self.tr.style) + self._sty.setFixedWidth(64) + self._sty.currentTextChanged.connect(lambda s: setattr(self.tr,"style",s)) + lay.addWidget(self._sty) + self._w = QDoubleSpinBox(); self._w.setObjectName("traceWidthSpin") + self._w.setRange(0.5,6.0); self._w.setSingleStep(0.5) + self._w.setValue(self.tr.width); self._w.setFixedWidth(56) + self._w.valueChanged.connect(lambda v: setattr(self.tr,"width",v)) + lay.addWidget(self._w) + rm = QToolButton(); rm.setText("✕"); rm.setObjectName("traceRemoveBtn") + rm.setFixedSize(22,22); rm.clicked.connect(lambda: self.removed.emit(self)) + lay.addWidget(rm) + + +# ══════════════════════════════════════════════════════════════════════════════ +# Pane block +# ══════════════════════════════════════════════════════════════════════════════ + +class PaneBlock(QFrame): + removed = pyqtSignal(object) + changed = pyqtSignal() + move_up = pyqtSignal(object) + move_dn = pyqtSignal(object) + + def __init__(self, spec: PaneSpec, registry: DeviceRegistry, + processor: SignalProcessor): + super().__init__() + self.spec = spec; self.registry = registry; self.processor = processor + self._rows: List[TraceRow] = [] + self.setObjectName("plotBlock"); self._build() + + def _build(self): + outer = QVBoxLayout(self); outer.setContentsMargins(0,0,0,0); outer.setSpacing(0) + + # Header + hdr = QWidget(); hdr.setObjectName("plotBlockHeader"); hdr.setFixedHeight(32) + hl = QHBoxLayout(hdr); hl.setContentsMargins(8,0,6,0); hl.setSpacing(4) + self._title = QLineEdit(self.spec.title); self._title.setObjectName("plotBlockTitle") + self._title.textChanged.connect(lambda t: setattr(self.spec,"title",t)) + hl.addWidget(self._title,1) + for txt, sig in [("▲",self.move_up),("▼",self.move_dn)]: + b = QToolButton(); b.setText(txt); b.setObjectName("plotMoveBtn") + b.setFixedSize(22,22); b.clicked.connect(lambda _,s=sig: s.emit(self)); hl.addWidget(b) + rm = QToolButton(); rm.setText("✕"); rm.setObjectName("plotRemoveBtn") + rm.setFixedSize(22,22); rm.clicked.connect(lambda: self.removed.emit(self)); hl.addWidget(rm) + outer.addWidget(hdr) + + # Settings row + sett = QWidget(); sett.setObjectName("plotBlockSettings") + sl = QHBoxLayout(sett); sl.setContentsMargins(8,6,8,6); sl.setSpacing(8) + + sl.addWidget(QLabel("X Axis:")) + self._x_cb = QComboBox(); self._x_cb.setObjectName("channelPickerCb") + self._x_cb.setMinimumWidth(150) + self._x_cb.addItem("⏱ Time (elapsed s)", userData="time") + for dev in self.registry.all_instances(): + for ch in dev.info.channels: + self._x_cb.addItem(f"{dev.info.device_id}/{ch.channel_id} ({ch.name})", + userData=f"{dev.info.device_id}/{ch.channel_id}") + for dc in self.processor.get_derived(): + self._x_cb.addItem(f"[derived] {dc.channel_id}", + userData=f"derived/{dc.channel_id}") + # Select current + for i in range(self._x_cb.count()): + if self._x_cb.itemData(i) == self.spec.x_source: + self._x_cb.setCurrentIndex(i); break + self._x_cb.currentIndexChanged.connect( + lambda: setattr(self.spec,"x_source", self._x_cb.currentData() or "time")) + sl.addWidget(self._x_cb) + + sl.addWidget(QLabel("Y:")) + self._ylabel = QLineEdit(self.spec.y_label); self._ylabel.setObjectName("plotYLabelEdit") + self._ylabel.setPlaceholderText("Y label"); self._ylabel.setMaximumWidth(110) + self._ylabel.textChanged.connect(lambda t: setattr(self.spec,"y_label",t)) + sl.addWidget(self._ylabel) + + self._auto = QCheckBox("Auto Y"); self._auto.setChecked(self.spec.y_auto) + self._auto.toggled.connect(self._on_auto); sl.addWidget(self._auto) + + sl.addWidget(QLabel("Min:")) + self._ymin = QDoubleSpinBox(); self._ymin.setRange(-1e9,1e9) + self._ymin.setValue(self.spec.y_min); self._ymin.setFixedWidth(72) + self._ymin.setEnabled(not self.spec.y_auto) + self._ymin.valueChanged.connect(lambda v: setattr(self.spec,"y_min",v)) + sl.addWidget(self._ymin) + + sl.addWidget(QLabel("Max:")) + self._ymax = QDoubleSpinBox(); self._ymax.setRange(-1e9,1e9) + self._ymax.setValue(self.spec.y_max); self._ymax.setFixedWidth(72) + self._ymax.setEnabled(not self.spec.y_auto) + self._ymax.valueChanged.connect(lambda v: setattr(self.spec,"y_max",v)) + sl.addWidget(self._ymax) + + self._grid = QCheckBox("Grid"); self._grid.setChecked(self.spec.grid) + self._grid.toggled.connect(lambda v: setattr(self.spec,"grid",v)) + sl.addWidget(self._grid) + + sl.addWidget(QLabel("Size:")) + self._wt = QSpinBox(); self._wt.setRange(1,10); self._wt.setValue(self.spec.weight) + self._wt.setFixedWidth(44); self._wt.setToolTip("Relative pane size") + self._wt.valueChanged.connect(lambda v: setattr(self.spec,"weight",v)) + sl.addWidget(self._wt); sl.addStretch() + outer.addWidget(sett) + + # Traces + tr_w = QWidget(); tr_w.setObjectName("plotBlockTraces") + self._tlay = QVBoxLayout(tr_w) + self._tlay.setContentsMargins(4,2,4,4); self._tlay.setSpacing(2) + for tr in self.spec.traces: self._add_row(tr) + + pick_row = QHBoxLayout() + self._picker = self._make_picker(); pick_row.addWidget(self._picker,1) + ab = QPushButton("+ Add Channel"); ab.setObjectName("addTraceBtn") + ab.clicked.connect(self._on_add); pick_row.addWidget(ab) + self._tlay.addLayout(pick_row) + outer.addWidget(tr_w) + + def _make_picker(self): + cb = QComboBox(); cb.setObjectName("channelPickerCb") + for dev in self.registry.all_instances(): + for ch in dev.info.channels: + cb.addItem(f"{dev.info.device_id} / {ch.channel_id} ({ch.name})", + userData=(dev.info.device_id, ch.channel_id, ch.name, ch.color)) + for dc in self.processor.get_derived(): + cb.addItem(f"[derived] {dc.channel_id} ({dc.name})", + userData=("derived", dc.channel_id, dc.name, dc.color)) + return cb + + def _add_row(self, tr: TraceSpec): + row = TraceRow(tr); row.removed.connect(self._rm_row) + row.changed.connect(self.changed) + self._rows.append(row) + self._tlay.insertWidget(self._tlay.count()-1, row) + + def _rm_row(self, row: TraceRow): + if row.tr in self.spec.traces: self.spec.traces.remove(row.tr) + self._rows.remove(row); self._tlay.removeWidget(row); row.deleteLater() + self.changed.emit() + + def _on_add(self): + data = self._picker.currentData() + if not data: return + d,c,n,col = data + tr = TraceSpec(device_id=d,channel_id=c,label=n,color=col) + self.spec.traces.append(tr); self._add_row(tr); self.changed.emit() + + def _on_auto(self, v): + self.spec.y_auto = v; self._ymin.setEnabled(not v); self._ymax.setEnabled(not v) + + +# ══════════════════════════════════════════════════════════════════════════════ +# PlotWindow +# ══════════════════════════════════════════════════════════════════════════════ + +class PlotWindow(QWidget): + layout_applied = pyqtSignal(object) # LayoutConfig + closed = pyqtSignal() + + def __init__(self, registry: DeviceRegistry, processor: SignalProcessor, + current: Optional[LayoutConfig] = None, parent=None): + super().__init__(parent, Qt.WindowType.Window | Qt.WindowType.Tool) + self.registry = registry + self.processor = processor + self.cfg = deepcopy(current) if current \ + else build_default_layout(registry, processor) + self.setWindowTitle("Plot Builder") + self.setMinimumSize(720, 560) + self.resize(820, 700) + self._blocks: List[PaneBlock] = [] + self._build() + self._populate() + + def _build(self): + root = QVBoxLayout(self); root.setContentsMargins(0,0,0,0); root.setSpacing(0) + + # Header + hdr = QWidget(); hdr.setObjectName("devWindowTitleBar"); hdr.setFixedHeight(44) + hl = QHBoxLayout(hdr); hl.setContentsMargins(14,0,10,0) + hl.addWidget(QLabel("PLOT BUILDER").also(lambda w: w.setObjectName("devWindowTitle")),1) + root.addWidget(hdr) + div = QFrame(); div.setFrameShape(QFrame.Shape.HLine) + div.setObjectName("devWindowDivider"); root.addWidget(div) + + # Global settings + glob = QWidget(); glob.setObjectName("cfgGlobalBar") + gl = QHBoxLayout(glob); gl.setContentsMargins(12,7,12,7); gl.setSpacing(12) + + # Layout mode + gl.addWidget(QLabel("Layout:")) + self._stacked_rb = QRadioButton("Stacked") + self._side_rb = QRadioButton("Side-by-Side") + self._grid_rb = QRadioButton("Grid") + mode_grp = QButtonGroup(self) + for rb in (self._stacked_rb, self._side_rb, self._grid_rb): + rb.setObjectName("cfgLiveChk"); mode_grp.addButton(rb) + mode_map = {"stacked": self._stacked_rb, + "sidebyside": self._side_rb, "grid": self._grid_rb} + mode_map.get(self.cfg.layout_mode, self._stacked_rb).setChecked(True) + for rb, key in [(self._stacked_rb,"stacked"),(self._side_rb,"sidebyside"),(self._grid_rb,"grid")]: + rb.toggled.connect(lambda v, k=key: self._set_mode(k) if v else None) + gl.addWidget(rb) + + gl.addWidget(QLabel("Cols:")) + self._cols = QSpinBox(); self._cols.setRange(1,6); self._cols.setValue(self.cfg.grid_cols) + self._cols.setFixedWidth(44); self._cols.setObjectName("traceWidthSpin") + self._cols.valueChanged.connect(lambda v: setattr(self.cfg,"grid_cols",v)) + gl.addWidget(self._cols) + + gl.addWidget(QLabel("Window:")) + self._win = QDoubleSpinBox(); self._win.setRange(1,3600) + self._win.setSuffix(" s"); self._win.setValue(self.cfg.time_window_s) + self._win.setObjectName("cfgGlobalSpin") + self._win.valueChanged.connect(lambda v: setattr(self.cfg,"time_window_s",v)) + gl.addWidget(self._win) + + self._linkx = QCheckBox("Link X"); self._linkx.setChecked(self.cfg.link_x) + self._linkx.setObjectName("cfgLiveChk") + self._linkx.toggled.connect(lambda v: setattr(self.cfg,"link_x",v)) + gl.addWidget(self._linkx) + + self._legend = QCheckBox("Legend"); self._legend.setChecked(self.cfg.show_legend) + self._legend.setObjectName("cfgLiveChk") + self._legend.toggled.connect(lambda v: setattr(self.cfg,"show_legend",v)) + gl.addWidget(self._legend) + + self._live = QCheckBox("Live preview"); self._live.setObjectName("cfgLiveChk") + gl.addWidget(self._live) + + gl.addStretch() + add_btn = QPushButton("+ Add Pane"); add_btn.setObjectName("addDeviceButton") + add_btn.clicked.connect(self._add_pane); gl.addWidget(add_btn) + root.addWidget(glob) + + div2 = QFrame(); div2.setFrameShape(QFrame.Shape.HLine) + div2.setObjectName("devWindowDivider"); root.addWidget(div2) + + # Scrollable pane blocks + scroll = QScrollArea(); scroll.setWidgetResizable(True) + scroll.setObjectName("deviceScroll") + self._cont = QWidget() + self._blay = QVBoxLayout(self._cont) + self._blay.setContentsMargins(10,10,10,10); self._blay.setSpacing(12) + self._blay.addStretch() + scroll.setWidget(self._cont) + root.addWidget(scroll,1) + + # Bottom + btm = QWidget(); btm.setObjectName("cfgBottomBar") + bl = QHBoxLayout(btm); bl.setContentsMargins(12,8,12,8) + for lbl, fn in [("⟳ Rebuild from Channels",self._reset), + ("↓ Export JSON",self._export), + ("↑ Import JSON",self._import_json)]: + b = QPushButton(lbl); b.setObjectName("configButton"); b.clicked.connect(fn); bl.addWidget(b) + bl.addStretch() + ap = QPushButton("✓ Apply"); ap.setObjectName("applyButton") + ap.clicked.connect(self._apply); bl.addWidget(ap) + root.addWidget(btm) + + def _set_mode(self, mode: str): + self.cfg.layout_mode = mode + self._maybe_live() + + def _populate(self): + for b in self._blocks: self._blay.removeWidget(b); b.deleteLater() + self._blocks.clear() + for spec in self.cfg.panes: self._insert(spec) + + def _insert(self, spec: PaneSpec): + blk = PaneBlock(spec, self.registry, self.processor) + blk.removed.connect(self._rm); blk.changed.connect(self._maybe_live) + blk.move_up.connect(self._mv_up); blk.move_dn.connect(self._mv_dn) + self._blocks.append(blk) + self._blay.insertWidget(self._blay.count()-1, blk) + + def _add_pane(self): + spec = PaneSpec(title=f"Plot {len(self.cfg.panes)+1}") + self.cfg.panes.append(spec); self._insert(spec); self._maybe_live() + + def _rm(self, blk): + if blk.spec in self.cfg.panes: self.cfg.panes.remove(blk.spec) + self._blocks.remove(blk); self._blay.removeWidget(blk); blk.deleteLater() + self._maybe_live() + + def _mv_up(self, blk): + i = self._blocks.index(blk) + if i==0: return + self.cfg.panes.insert(i-1,self.cfg.panes.pop(i)) + self._blocks.insert(i-1,self._blocks.pop(i)) + self._blay.removeWidget(blk); self._blay.insertWidget(i-1,blk) + self._maybe_live() + + def _mv_dn(self, blk): + i = self._blocks.index(blk) + if i>=len(self._blocks)-1: return + self.cfg.panes.insert(i+1,self.cfg.panes.pop(i)) + self._blocks.insert(i+1,self._blocks.pop(i)) + self._blay.removeWidget(blk); self._blay.insertWidget(i+1,blk) + self._maybe_live() + + def _maybe_live(self): + if self._live.isChecked(): self.layout_applied.emit(deepcopy(self.cfg)) + + def _apply(self): self.layout_applied.emit(deepcopy(self.cfg)) + + def _reset(self): + self.cfg = build_default_layout(self.registry, self.processor) + self._win.setValue(self.cfg.time_window_s) + self._populate(); self._maybe_live() + + def _export(self): + path, _ = QFileDialog.getSaveFileName(self,"Export","layout.json","JSON (*.json)") + if path: open(path,"w").write(self.cfg.to_json()) + + def _import_json(self): + path, _ = QFileDialog.getOpenFileName(self,"Import","","JSON (*.json)") + if path: + try: + self.cfg = LayoutConfig.from_json(open(path).read()) + self._win.setValue(self.cfg.time_window_s) + self._populate(); self._maybe_live() + except Exception as e: + QMessageBox.critical(self,"Import failed",str(e)) + + def refresh_channels(self): + self._populate() + + def closeEvent(self, e: QCloseEvent): + self.closed.emit(); e.accept() + + +# monkey-patch QLabel.also for inline objectName setting +from PyQt6.QtWidgets import QLabel as _QLabel2 +def _also2(self, fn): fn(self); return self +if not hasattr(_QLabel2, "also"): _QLabel2.also = _also2 diff --git a/ui/windows/settings_window.py b/ui/windows/settings_window.py new file mode 100644 index 0000000..88093ff --- /dev/null +++ b/ui/windows/settings_window.py @@ -0,0 +1,231 @@ +""" +ui/windows/settings_window.py + +SETTINGS window — toolbar section 5. + +Tabs: + General — theme (dark/light/system), font sizes, polling rate + Acquisition — sample rate, buffer size, CSV log directory + Display — default time window, legend, grid defaults + Controls — configure output widget assignments per channel +""" + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, + QTabWidget, QFrame, QFormLayout, QGroupBox, QComboBox, + QSpinBox, QDoubleSpinBox, QCheckBox, QLineEdit, + QFileDialog, QScrollArea, QSizePolicy, +) +from PyQt6.QtCore import Qt, pyqtSignal +from PyQt6.QtGui import QCloseEvent, QFont + +from devices.device_registry import DeviceRegistry +from core.acquisition import AcquisitionEngine + + +class SettingsWindow(QWidget): + theme_changed = pyqtSignal(str) # "dark" | "light" + settings_changed = pyqtSignal(dict) + closed = pyqtSignal() + + # Shared settings dict — written on Apply, read by consumers + _defaults = { + "theme": "dark", + "poll_ms": 100, + "buffer_size": 20000, + "log_dir": "logs", + "time_window_s": 30.0, + "show_legend": True, + "show_grid": True, + "font_size": 12, + "antialias": True, + } + + def __init__(self, registry: DeviceRegistry, + engine: AcquisitionEngine, + current: dict = None, parent=None): + super().__init__(parent, Qt.WindowType.Window | Qt.WindowType.Tool) + self.registry = registry + self.engine = engine + self.cfg = dict(self._defaults) + if current: + self.cfg.update(current) + + self.setWindowTitle("Settings") + self.setMinimumSize(520, 440) + self.resize(560, 520) + self._build() + + def _build(self): + root = QVBoxLayout(self); root.setContentsMargins(0,0,0,0); root.setSpacing(0) + + # Header + hdr = QWidget(); hdr.setObjectName("devWindowTitleBar"); hdr.setFixedHeight(44) + hl = QHBoxLayout(hdr); hl.setContentsMargins(14,0,14,0) + hl.addWidget(QLabel("SETTINGS").also(lambda w: w.setObjectName("devWindowTitle"))) + root.addWidget(hdr) + div = QFrame(); div.setFrameShape(QFrame.Shape.HLine) + div.setObjectName("devWindowDivider"); root.addWidget(div) + + tabs = QTabWidget(); tabs.setObjectName("signalBuilderTabs") + root.addWidget(tabs, 1) + + tabs.addTab(self._general_tab(), " General ") + tabs.addTab(self._acquisition_tab(), " Acquisition ") + tabs.addTab(self._display_tab(), " Display ") + tabs.addTab(self._controls_tab(), " Controls ") + + # Bottom bar + btm = QWidget(); btm.setObjectName("cfgBottomBar") + bl = QHBoxLayout(btm); bl.setContentsMargins(12,8,12,8) + bl.addStretch() + ap = QPushButton("✓ Apply & Close"); ap.setObjectName("applyButton") + ap.clicked.connect(self._apply); bl.addWidget(ap) + root.addWidget(btm) + + # ── Tabs ────────────────────────────────────────────────────────────── + + def _general_tab(self): + w = QWidget() + scroll = QScrollArea(); scroll.setWidgetResizable(True) + scroll.setObjectName("deviceScroll") + cont = QWidget(); lay = QFormLayout(cont) + lay.setContentsMargins(16,14,16,14); lay.setSpacing(10) + + self._theme_cb = QComboBox(); self._theme_cb.setObjectName("channelPickerCb") + self._theme_cb.addItems(["Dark", "Light"]) + self._theme_cb.setCurrentText(self.cfg["theme"].title()) + lay.addRow("Theme:", self._theme_cb) + + self._font_sp = QSpinBox(); self._font_sp.setRange(8,18) + self._font_sp.setValue(self.cfg["font_size"]); self._font_sp.setSuffix(" pt") + self._font_sp.setObjectName("traceWidthSpin") + lay.addRow("Base font size:", self._font_sp) + + self._aa_chk = QCheckBox(); self._aa_chk.setChecked(self.cfg["antialias"]) + lay.addRow("Anti-alias plots:", self._aa_chk) + + scroll.setWidget(cont) + root = QVBoxLayout(w); root.setContentsMargins(0,0,0,0); root.addWidget(scroll) + return w + + def _acquisition_tab(self): + w = QWidget() + scroll = QScrollArea(); scroll.setWidgetResizable(True) + scroll.setObjectName("deviceScroll") + cont = QWidget(); lay = QFormLayout(cont) + lay.setContentsMargins(16,14,16,14); lay.setSpacing(10) + + self._poll_sp = QSpinBox(); self._poll_sp.setRange(10,5000) + self._poll_sp.setValue(self.cfg["poll_ms"]); self._poll_sp.setSuffix(" ms") + self._poll_sp.setObjectName("traceWidthSpin") + lay.addRow("Poll interval:", self._poll_sp) + + self._buf_sp = QSpinBox(); self._buf_sp.setRange(1000,500000) + self._buf_sp.setValue(self.cfg["buffer_size"]) + self._buf_sp.setObjectName("traceWidthSpin") + lay.addRow("Buffer size (samples):", self._buf_sp) + + log_row = QHBoxLayout() + self._log_edit = QLineEdit(self.cfg["log_dir"]) + self._log_edit.setObjectName("traceLabel"); log_row.addWidget(self._log_edit,1) + br = QPushButton("Browse"); br.setObjectName("configButton") + br.clicked.connect(self._browse_log); log_row.addWidget(br) + lay.addRow("Log directory:", log_row) + + scroll.setWidget(cont) + root = QVBoxLayout(w); root.setContentsMargins(0,0,0,0); root.addWidget(scroll) + return w + + def _display_tab(self): + w = QWidget() + scroll = QScrollArea(); scroll.setWidgetResizable(True) + scroll.setObjectName("deviceScroll") + cont = QWidget(); lay = QFormLayout(cont) + lay.setContentsMargins(16,14,16,14); lay.setSpacing(10) + + self._tw_sp = QDoubleSpinBox(); self._tw_sp.setRange(1,3600) + self._tw_sp.setValue(self.cfg["time_window_s"]); self._tw_sp.setSuffix(" s") + self._tw_sp.setObjectName("cfgGlobalSpin") + lay.addRow("Default time window:", self._tw_sp) + + self._legend_chk = QCheckBox(); self._legend_chk.setChecked(self.cfg["show_legend"]) + lay.addRow("Show legend by default:", self._legend_chk) + + self._grid_chk = QCheckBox(); self._grid_chk.setChecked(self.cfg["show_grid"]) + lay.addRow("Show grid by default:", self._grid_chk) + + scroll.setWidget(cont) + root = QVBoxLayout(w); root.setContentsMargins(0,0,0,0); root.addWidget(scroll) + return w + + def _controls_tab(self): + """Output channel assignments — which device channel each control widget drives.""" + w = QWidget() + scroll = QScrollArea(); scroll.setWidgetResizable(True) + scroll.setObjectName("deviceScroll") + cont = QWidget(); lay = QVBoxLayout(cont) + lay.setContentsMargins(14,12,14,12); lay.setSpacing(8) + + info = QLabel( + "Configure which physical output channels are driven by each control widget.\n" + "Add output widget mappings below. Changes take effect on next app start." + ) + info.setObjectName("traceSource"); info.setWordWrap(True) + lay.addWidget(info) + + grp = QGroupBox("Output Assignments") + g_lay = QFormLayout(grp); g_lay.setSpacing(6) + + # Collect digital output channels + out_channels = ["— none —"] + for dev in self.registry.all_instances(): + for ch in dev.info.channels: + if ch.channel_id.startswith("do") or "out" in ch.channel_id.lower(): + out_channels.append(f"{dev.info.device_id} / {ch.channel_id} ({ch.name})") + + self._out_combos = {} + for label in ["Pump Power", "Heater", "Motor Enable", "PWM Ch 1"]: + cb = QComboBox(); cb.setObjectName("channelPickerCb") + cb.addItems(out_channels) + g_lay.addRow(f"{label}:", cb) + self._out_combos[label] = cb + + lay.addWidget(grp) + lay.addStretch() + scroll.setWidget(cont) + root = QVBoxLayout(w); root.setContentsMargins(0,0,0,0); root.addWidget(scroll) + return w + + # ── Actions ─────────────────────────────────────────────────────────── + + def _browse_log(self): + path = QFileDialog.getExistingDirectory(self, "Select Log Directory") + if path: + self._log_edit.setText(path) + + def _apply(self): + theme = self._theme_cb.currentText().lower() + self.cfg.update({ + "theme": theme, + "font_size": self._font_sp.value(), + "antialias": self._aa_chk.isChecked(), + "poll_ms": self._poll_sp.value(), + "buffer_size": self._buf_sp.value(), + "log_dir": self._log_edit.text(), + "time_window_s": self._tw_sp.value(), + "show_legend": self._legend_chk.isChecked(), + "show_grid": self._grid_chk.isChecked(), + }) + self.theme_changed.emit(theme) + self.settings_changed.emit(dict(self.cfg)) + self.hide() + + def closeEvent(self, e: QCloseEvent): + self.closed.emit(); e.accept() + + +# monkey-patch QLabel.also +from PyQt6.QtWidgets import QLabel as _QL +def _also3(self, fn): fn(self); return self +if not hasattr(_QL, "also"): _QL.also = _also3 diff --git a/ui/windows/signals_window.py b/ui/windows/signals_window.py new file mode 100644 index 0000000..eb7730a --- /dev/null +++ b/ui/windows/signals_window.py @@ -0,0 +1,659 @@ +""" +ui/windows/signals_window.py + +SIGNALS window — toolbar section 3. + +Tabs: + Pipeline — per-channel filter stack (filters, calibration, scale/offset) + Derived — create virtual channels from physical ones + Channels — visibility & color management for all channels + +CALIBRATION: A dedicated calibration section inside Pipeline lets the operator +enter known input→output pairs and fits a linear (or polynomial) cal curve, +which is applied as a ScaleOffset filter. + +DERIVED includes: + - displacement from pot voltage (calibrated) + - velocity from displacement (backward difference) + - acceleration from velocity + - power (V*I) + - expression (x[0]*2 + 1) + - custom Python function/script +""" + +from __future__ import annotations +import math +from typing import List, Optional, Tuple + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, + QScrollArea, QFrame, QTabWidget, QComboBox, QLineEdit, + QDoubleSpinBox, QSpinBox, QCheckBox, QTextEdit, QGroupBox, + QToolButton, QSizePolicy, QTableWidget, QTableWidgetItem, + QHeaderView, QAbstractItemView, QFormLayout, QMessageBox, +) +from PyQt6.QtCore import Qt, pyqtSignal +from PyQt6.QtGui import QColor, QFont, QCloseEvent + +from devices.device_registry import DeviceRegistry +from core.signal_processor import ( + SignalProcessor, ChannelPipeline, DerivedChannel, + FilterBase, MovingAverageFilter, MedianFilter, LowPassFilter, + HighPassFilter, ScaleOffsetFilter, DerivativeFilter, IntegralFilter, + FILTER_CLASSES, +) + +_DERIVED_COLORS = [ + "#f72585","#7209b7","#4361ee","#4cc9f0", + "#f77f00","#d62828","#588157","#e9c46a", +] + +def _channel_combo(registry: DeviceRegistry, + processor: Optional[SignalProcessor] = None, + include_derived: bool = False) -> QComboBox: + cb = QComboBox(); cb.setObjectName("channelPickerCb") + for dev in registry.all_instances(): + for ch in dev.info.channels: + cb.addItem(f"{dev.info.device_id} / {ch.channel_id} ({ch.name})", + userData=(dev.info.device_id, ch.channel_id)) + if include_derived and processor: + for dc in processor.get_derived(): + cb.addItem(f"[derived] {dc.channel_id} ({dc.name})", + userData=("derived", dc.channel_id)) + return cb + + +# ══════════════════════════════════════════════════════════════════════════════ +# Filter row +# ══════════════════════════════════════════════════════════════════════════════ + +_PARAM_SPECS = { + "moving_average": [("window", "int", 10, 1, 5000)], + "median": [("window", "int", 5, 1, 500)], + "low_pass": [("alpha", "float", 0.1, 0.001, 1.0)], + "high_pass": [("alpha", "float", 0.9, 0.001, 1.0)], + "scale_offset": [("scale", "float", 1.0, -1e6, 1e6), + ("offset", "float", 0.0, -1e9, 1e9)], + "derivative": [], + "integral": [], +} + +class FilterRow(QFrame): + removed = pyqtSignal(object) + changed = pyqtSignal() + + def __init__(self, filt: FilterBase): + super().__init__() + self.filt = filt + self.setObjectName("traceRow") + self._build() + + def _build(self): + lay = QHBoxLayout(self); lay.setContentsMargins(6,4,6,4); lay.setSpacing(8) + + lbl = QLabel(self.filt.name.replace("_"," ").title()) + lbl.setObjectName("traceSource"); lbl.setMinimumWidth(120) + lay.addWidget(lbl) + + for pname, ptype, default, lo, hi in _PARAM_SPECS.get(self.filt.name, []): + lay.addWidget(QLabel(f"{pname}:").also(lambda w: w.setObjectName("pwmLabel"))) + if ptype == "int": + w = QSpinBox(); w.setRange(int(lo), int(hi)) + w.setValue(int(self.filt.params.get(pname, default))) + w.setFixedWidth(70); w.setObjectName("traceWidthSpin") + w.valueChanged.connect(lambda v, p=pname: self._upd(p, v)) + else: + w = QDoubleSpinBox(); w.setRange(lo, hi); w.setDecimals(4) + w.setValue(float(self.filt.params.get(pname, default))) + w.setFixedWidth(88); w.setObjectName("traceWidthSpin") + w.valueChanged.connect(lambda v, p=pname: self._upd(p, v)) + lay.addWidget(w) + + lay.addStretch() + rm = QToolButton(); rm.setText("✕"); rm.setObjectName("traceRemoveBtn") + rm.setFixedSize(22, 22); rm.clicked.connect(lambda: self.removed.emit(self)) + lay.addWidget(rm) + + def _upd(self, name, value): + self.filt.params[name] = value + cls = FILTER_CLASSES.get(self.filt.name) + if cls: + nf = cls(**self.filt.params); self.filt.__dict__.update(nf.__dict__) + self.changed.emit() + + +# monkey-patch QLabel.also so we can set objectName inline above +from PyQt6.QtWidgets import QLabel as _QLabel +def _also(self, fn): fn(self); return self +_QLabel.also = _also + + +# ══════════════════════════════════════════════════════════════════════════════ +# Calibration widget +# ══════════════════════════════════════════════════════════════════════════════ + +class CalibrationWidget(QGroupBox): + """ + Linear calibration: enter N (raw, engineering) point pairs, + fit y = scale*x + offset, apply as ScaleOffsetFilter. + """ + cal_applied = pyqtSignal(float, float) # scale, offset + + def __init__(self, parent=None): + super().__init__("Calibration", parent) + self._points: List[Tuple[float,float]] = [] + self._build() + + def _build(self): + lay = QVBoxLayout(self); lay.setSpacing(4) + + # Table of raw→engineering pairs + self._tbl = QTableWidget(0, 2) + self._tbl.setHorizontalHeaderLabels(["Raw (sensor)", "Engineering"]) + self._tbl.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) + self._tbl.setMaximumHeight(130) + self._tbl.setObjectName("channelTable") + lay.addWidget(self._tbl) + + btn_row = QHBoxLayout() + add_pt = QPushButton("+ Point"); add_pt.setObjectName("addTraceBtn") + add_pt.clicked.connect(self._add_row) + rm_pt = QPushButton("✕ Remove"); rm_pt.setObjectName("configButton") + rm_pt.clicked.connect(self._rm_row) + btn_row.addWidget(add_pt); btn_row.addWidget(rm_pt); btn_row.addStretch() + lay.addLayout(btn_row) + + # Result + self._result_lbl = QLabel("") + self._result_lbl.setObjectName("traceSource"); lay.addWidget(self._result_lbl) + + fit_btn = QPushButton("Fit & Apply"); fit_btn.setObjectName("applyButton") + fit_btn.clicked.connect(self._fit); lay.addWidget(fit_btn) + + def _add_row(self): + r = self._tbl.rowCount(); self._tbl.insertRow(r) + self._tbl.setItem(r, 0, QTableWidgetItem("0.0")) + self._tbl.setItem(r, 1, QTableWidgetItem("0.0")) + + def _rm_row(self): + rows = {i.row() for i in self._tbl.selectedItems()} + for r in sorted(rows, reverse=True): + self._tbl.removeRow(r) + + def _fit(self): + pts = [] + for r in range(self._tbl.rowCount()): + try: + x = float(self._tbl.item(r,0).text()) + y = float(self._tbl.item(r,1).text()) + pts.append((x,y)) + except Exception: + pass + if len(pts) < 2: + self._result_lbl.setText("⚠ Need ≥2 points"); return + xs = [p[0] for p in pts]; ys = [p[1] for p in pts] + n = len(pts) + sx = sum(xs); sy = sum(ys) + sxx = sum(x*x for x in xs); sxy = sum(x*y for x,y in pts) + denom = n*sxx - sx*sx + if abs(denom) < 1e-12: + self._result_lbl.setText("⚠ Singular — all raw values identical"); return + scale = (n*sxy - sx*sy) / denom + offset = (sy - scale*sx) / n + self._result_lbl.setText( + f"y = {scale:.5g} × x + {offset:.5g} (R²≈{self._r2(xs,ys,scale,offset):.4f})" + ) + self._result_lbl.setStyleSheet("color:#22c55e;") + self.cal_applied.emit(scale, offset) + + @staticmethod + def _r2(xs, ys, scale, offset): + y_mean = sum(ys)/len(ys) + ss_tot = sum((y-y_mean)**2 for y in ys) + ss_res = sum((y - (scale*x+offset))**2 for x,y in zip(xs,ys)) + return 1 - ss_res/ss_tot if ss_tot else 1.0 + + +# ══════════════════════════════════════════════════════════════════════════════ +# Pipeline Tab — filter stack + calibration for one channel +# ══════════════════════════════════════════════════════════════════════════════ + +class PipelineTab(QWidget): + changed = pyqtSignal() + + def __init__(self, registry: DeviceRegistry, processor: SignalProcessor): + super().__init__() + self.registry = registry + self.processor = processor + self._rows: List[FilterRow] = [] + self._cur_pipeline: Optional[ChannelPipeline] = None + self._build() + + def _build(self): + lay = QVBoxLayout(self); lay.setContentsMargins(0,0,0,0); lay.setSpacing(0) + + # Channel selector bar + sel_bar = QWidget(); sel_bar.setObjectName("cfgGlobalBar") + sb_lay = QHBoxLayout(sel_bar); sb_lay.setContentsMargins(10,7,10,7) + sb_lay.addWidget(QLabel("Channel:")) + self._ch_cb = _channel_combo(self.registry, self.processor, include_derived=True) + self._ch_cb.currentIndexChanged.connect(self._load_pipeline) + sb_lay.addWidget(self._ch_cb, 1) + lay.addWidget(sel_bar) + + div = QFrame(); div.setFrameShape(QFrame.Shape.HLine) + div.setObjectName("devWindowDivider"); lay.addWidget(div) + + scroll = QScrollArea(); scroll.setWidgetResizable(True) + scroll.setObjectName("deviceScroll") + scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self._cont = QWidget() + self._inner = QVBoxLayout(self._cont) + self._inner.setContentsMargins(10,10,10,10); self._inner.setSpacing(6) + + # Enable toggle + self._en_chk = QCheckBox("Pipeline enabled"); self._en_chk.setChecked(True) + self._en_chk.toggled.connect(self._on_enable) + self._inner.addWidget(self._en_chk) + + # Filter rows area + self._filter_area = QWidget() + self._filter_lay = QVBoxLayout(self._filter_area) + self._filter_lay.setContentsMargins(0,0,0,0); self._filter_lay.setSpacing(3) + self._inner.addWidget(self._filter_area) + + # Add filter row + add_bar = QHBoxLayout() + self._filt_cb = QComboBox(); self._filt_cb.setObjectName("channelPickerCb") + self._filt_cb.addItems([k.replace("_"," ").title() for k in FILTER_CLASSES]) + add_bar.addWidget(self._filt_cb, 1) + add_btn = QPushButton("+ Add Filter"); add_btn.setObjectName("addTraceBtn") + add_btn.clicked.connect(self._add_filter) + add_bar.addWidget(add_btn) + self._inner.addLayout(add_bar) + + # Calibration + self._cal_widget = CalibrationWidget() + self._cal_widget.cal_applied.connect(self._apply_calibration) + self._inner.addWidget(self._cal_widget) + self._inner.addStretch() + + scroll.setWidget(self._cont) + lay.addWidget(scroll, 1) + self._load_pipeline(0) + + def _load_pipeline(self, _=None): + data = self._ch_cb.currentData() + if not data: return + dev_id, ch_id = data + + # Clear filter rows + for r in self._rows: + self._filter_lay.removeWidget(r); r.deleteLater() + self._rows.clear() + + pipeline = self.processor.get_pipeline(dev_id, ch_id) + if pipeline is None: + pipeline = ChannelPipeline(device_id=dev_id, channel_id=ch_id) + self.processor.set_pipeline(pipeline) + self._cur_pipeline = pipeline + self._en_chk.setChecked(pipeline.enabled) + for f in pipeline.filters: + self._add_filter_row(f) + + def _add_filter(self): + key = list(FILTER_CLASSES.keys())[self._filt_cb.currentIndex()] + filt = FILTER_CLASSES[key]() + if self._cur_pipeline: + self._cur_pipeline.filters.append(filt) + self._add_filter_row(filt) + self._push() + + def _add_filter_row(self, filt: FilterBase): + row = FilterRow(filt) + row.removed.connect(self._remove_filter) + row.changed.connect(self._push) + self._rows.append(row) + self._filter_lay.addWidget(row) + + def _remove_filter(self, row: FilterRow): + if self._cur_pipeline and row.filt in self._cur_pipeline.filters: + self._cur_pipeline.filters.remove(row.filt) + self._rows.remove(row) + self._filter_lay.removeWidget(row); row.deleteLater() + self._push() + + def _on_enable(self, v: bool): + if self._cur_pipeline: + self._cur_pipeline.enabled = v; self._push() + + def _apply_calibration(self, scale: float, offset: float): + """Insert/replace a scale_offset filter at the END of the pipeline.""" + if self._cur_pipeline is None: return + # Remove existing scale_offset + self._cur_pipeline.filters = [ + f for f in self._cur_pipeline.filters if f.name != "scale_offset" + ] + cal_filt = ScaleOffsetFilter(scale=scale, offset=offset) + self._cur_pipeline.filters.append(cal_filt) + # Refresh rows + for r in self._rows: + self._filter_lay.removeWidget(r); r.deleteLater() + self._rows.clear() + for f in self._cur_pipeline.filters: + self._add_filter_row(f) + self._push() + + def _push(self): + if self._cur_pipeline: + self.processor.set_pipeline(self._cur_pipeline) + self.changed.emit() + + +# ══════════════════════════════════════════════════════════════════════════════ +# Derived editor for one DerivedChannel +# ══════════════════════════════════════════════════════════════════════════════ + +_BUILTIN = ["velocity","acceleration","power","rms","difference","sum"] +_SCRIPT = ["expression","function","custom_script"] +_ALL_KINDS = _BUILTIN + _SCRIPT + +_KIND_HELP = { + "velocity": "Backward difference dy/dt from 1 displacement source", + "acceleration": "Second derivative d²y/dt² from 1 displacement source", + "power": "V × I from 2 sources (voltage, current)", + "rms": "Rolling RMS from 1 source (set window below)", + "difference": "source[0] − source[1] from 2 sources", + "sum": "Σ all source values", + "expression": "x = list of source values, t = elapsed time (s)\nExample: x[0] * 3.14159 / 180", + "function": "def compute(x, t):\n # x = source values list\n return x[0] * 2.0", + "custom_script":"Full Python. Must define: def compute(x, t): ...", +} + +class DerivedBlock(QFrame): + removed = pyqtSignal(object) + applied = pyqtSignal(object) # DerivedChannel + + def __init__(self, dc: DerivedChannel, registry: DeviceRegistry, + processor: SignalProcessor): + super().__init__() + self.dc = dc; self.registry = registry; self.processor = processor + self.setObjectName("plotBlock") + self._src_combos: List[QComboBox] = [] + self._build() + + def _build(self): + outer = QVBoxLayout(self); outer.setContentsMargins(0,0,0,0); outer.setSpacing(0) + + # Header + hdr = QWidget(); hdr.setObjectName("plotBlockHeader"); hdr.setFixedHeight(32) + hl = QHBoxLayout(hdr); hl.setContentsMargins(8,0,6,0) + self._name = QLineEdit(self.dc.name); self._name.setObjectName("plotBlockTitle") + self._name.textChanged.connect(lambda t: setattr(self.dc,"name",t)) + hl.addWidget(self._name, 1) + self._en = QCheckBox(); self._en.setChecked(self.dc.enabled) + self._en.setToolTip("Enable"); self._en.toggled.connect(lambda v: setattr(self.dc,"enabled",v)) + hl.addWidget(self._en) + rm = QToolButton(); rm.setText("✕"); rm.setObjectName("plotRemoveBtn") + rm.setFixedSize(22,22); rm.clicked.connect(lambda: self.removed.emit(self)) + hl.addWidget(rm) + outer.addWidget(hdr) + + # Body + body = QWidget(); body.setObjectName("plotBlockSettings") + bl = QVBoxLayout(body); bl.setContentsMargins(10,8,10,10); bl.setSpacing(6) + + # ID / unit / color row + meta = QHBoxLayout() + meta.addWidget(QLabel("ID:")); self._id = QLineEdit(self.dc.channel_id) + self._id.setObjectName("traceLabel"); self._id.setFixedWidth(100) + self._id.textChanged.connect(lambda t: setattr(self.dc,"channel_id",t)) + meta.addWidget(self._id) + meta.addWidget(QLabel("Unit:")); self._unit = QLineEdit(self.dc.unit) + self._unit.setObjectName("traceLabel"); self._unit.setFixedWidth(60) + self._unit.textChanged.connect(lambda t: setattr(self.dc,"unit",t)) + meta.addWidget(self._unit); meta.addStretch() + bl.addLayout(meta) + + # Kind + help + k_row = QHBoxLayout(); k_row.addWidget(QLabel("Kind:")) + self._kind_cb = QComboBox(); self._kind_cb.setObjectName("channelPickerCb") + self._kind_cb.addItems(_ALL_KINDS); self._kind_cb.setCurrentText(self.dc.kind) + self._kind_cb.currentTextChanged.connect(self._on_kind) + k_row.addWidget(self._kind_cb); k_row.addStretch(); bl.addLayout(k_row) + self._help = QLabel(_KIND_HELP.get(self.dc.kind,"")) + self._help.setObjectName("traceSource"); self._help.setWordWrap(True) + bl.addWidget(self._help) + + # RMS window (shown for rms) + self._win_row = QHBoxLayout() + self._win_row.addWidget(QLabel("Window samples:")) + self._win_sp = QSpinBox(); self._win_sp.setRange(2,10000) + self._win_sp.setValue(self.dc.params.get("window",20)) + self._win_sp.setObjectName("traceWidthSpin"); self._win_sp.setFixedWidth(80) + self._win_sp.valueChanged.connect(lambda v: self.dc.params.update({"window":v})) + self._win_row.addWidget(self._win_sp); self._win_row.addStretch() + self._win_widget = QWidget(); self._win_widget.setLayout(self._win_row) + bl.addWidget(self._win_widget) + + # Sources + src_grp = QGroupBox("Sources"); src_grp.setObjectName("cfgGlobalBar") + src_lay = QVBoxLayout(src_grp); src_lay.setContentsMargins(6,4,6,4); src_lay.setSpacing(3) + self._src_cont = QWidget(); self._src_vlay = QVBoxLayout(self._src_cont) + self._src_vlay.setContentsMargins(0,0,0,0); self._src_vlay.setSpacing(3) + for s in self.dc.sources: self._add_src(s) + add_s = QPushButton("+ Add Source"); add_s.setObjectName("addTraceBtn") + add_s.clicked.connect(lambda: self._add_src()) + src_lay.addWidget(self._src_cont); src_lay.addWidget(add_s) + bl.addWidget(src_grp) + + # Code editor + self._code_grp = QGroupBox("Code") + code_lay = QVBoxLayout(self._code_grp); code_lay.setContentsMargins(6,4,6,4) + self._code = QTextEdit(); self._code.setObjectName("codeEditor") + self._code.setMinimumHeight(90); self._code.setMaximumHeight(180) + txt = self.dc.expression if self.dc.kind=="expression" else self.dc.script + self._code.setPlainText(txt) + code_lay.addWidget(self._code) + bl.addWidget(self._code_grp) + + # Status + apply + ap_row = QHBoxLayout() + self._st = QLabel(""); self._st.setObjectName("traceSource") + ap_row.addWidget(self._st, 1) + ap = QPushButton("✓ Apply"); ap.setObjectName("applyButton") + ap.clicked.connect(self._apply); ap_row.addWidget(ap) + bl.addLayout(ap_row) + + outer.addWidget(body) + self._update_visibility() + + def _add_src(self, src=None): + row = QHBoxLayout() + cb = _channel_combo(self.registry, include_derived=False) + cb.setObjectName("channelPickerCb") + if src: + for i in range(cb.count()): + if cb.itemData(i) == src: cb.setCurrentIndex(i); break + rm = QToolButton(); rm.setText("✕"); rm.setObjectName("traceRemoveBtn") + rm.setFixedSize(22,22); rm.clicked.connect(lambda: self._rm_src(row, cb)) + row.addWidget(cb,1); row.addWidget(rm) + self._src_vlay.addLayout(row) + self._src_combos.append(cb) + + def _rm_src(self, row, cb): + if cb in self._src_combos: self._src_combos.remove(cb) + while row.count(): + it = row.takeAt(0) + if it.widget(): it.widget().deleteLater() + + def _on_kind(self, k: str): + self.dc.kind = k + self._help.setText(_KIND_HELP.get(k,"")) + self._update_visibility() + + def _update_visibility(self): + is_script = self.dc.kind in _SCRIPT + is_rms = self.dc.kind == "rms" + self._code_grp.setVisible(is_script) + self._win_widget.setVisible(is_rms) + + def _apply(self): + self.dc.sources = [cb.currentData() for cb in self._src_combos if cb.currentData()] + if self.dc.kind == "expression": + self.dc.expression = self._code.toPlainText().strip() + elif self.dc.kind in ("function","custom_script"): + self.dc.script = self._code.toPlainText() + err = self.processor.add_derived(self.dc) + if err: + self._st.setText(f"⚠ {err[:100]}") + self._st.setStyleSheet("color:#ef4444;") + else: + self._st.setText("✓ Applied") + self._st.setStyleSheet("color:#22c55e;") + self.applied.emit(self.dc) + + +# ══════════════════════════════════════════════════════════════════════════════ +# Channel Visibility Tab +# ══════════════════════════════════════════════════════════════════════════════ + +class ChannelVisTab(QWidget): + changed = pyqtSignal() + + def __init__(self, registry: DeviceRegistry, processor: SignalProcessor): + super().__init__() + self.registry = registry + self.processor = processor + self._build() + + def _build(self): + scroll = QScrollArea(); scroll.setWidgetResizable(True) + scroll.setObjectName("deviceScroll") + self._cont = QWidget() + self._lay = QVBoxLayout(self._cont) + self._lay.setContentsMargins(10,10,10,10); self._lay.setSpacing(8) + scroll.setWidget(self._cont) + root = QVBoxLayout(self); root.setContentsMargins(0,0,0,0) + root.addWidget(scroll) + self.refresh() + + def refresh(self): + while self._lay.count(): + it = self._lay.takeAt(0) + if it.widget(): it.widget().deleteLater() + for dev in self.registry.all_instances(): + grp = QGroupBox(f"{dev.info.icon} {dev.info.name} [{dev.info.device_id}]") + g_lay = QVBoxLayout(grp); g_lay.setSpacing(3) + for ch in dev.info.channels: + row = QHBoxLayout() + chk = QCheckBox(f"{ch.channel_id} — {ch.name}") + chk.setChecked(ch.enabled) + chk.setStyleSheet(f"color:{ch.color};") + chk.toggled.connect(lambda v, c=ch: (setattr(c,"enabled",v), self.changed.emit())) + unit = QLabel(ch.unit); unit.setObjectName("traceSource") + row.addWidget(chk,1); row.addWidget(unit) + g_lay.addLayout(row) + self._lay.addWidget(grp) + self._lay.addStretch() + + +# ══════════════════════════════════════════════════════════════════════════════ +# SignalsWindow — main +# ══════════════════════════════════════════════════════════════════════════════ + +class SignalsWindow(QWidget): + pipeline_changed = pyqtSignal() + derived_changed = pyqtSignal() + visibility_changed = pyqtSignal() + closed = pyqtSignal() + + def __init__(self, registry: DeviceRegistry, + processor: SignalProcessor, parent=None): + super().__init__(parent, Qt.WindowType.Window | Qt.WindowType.Tool) + self.registry = registry + self.processor = processor + self._derived_blocks: List[DerivedBlock] = [] + self._color_idx = 0 + + self.setWindowTitle("Signals") + self.setMinimumSize(640, 520) + self.resize(740, 640) + self._build() + + def _build(self): + root = QVBoxLayout(self); root.setContentsMargins(0,0,0,0); root.setSpacing(0) + + # Header + hdr = QWidget(); hdr.setObjectName("devWindowTitleBar"); hdr.setFixedHeight(44) + hl = QHBoxLayout(hdr); hl.setContentsMargins(14,0,14,0) + hl.addWidget(QLabel("SIGNALS").also(lambda w: w.setObjectName("devWindowTitle")), 1) + root.addWidget(hdr) + div = QFrame(); div.setFrameShape(QFrame.Shape.HLine) + div.setObjectName("devWindowDivider"); root.addWidget(div) + + tabs = QTabWidget(); tabs.setObjectName("signalBuilderTabs") + root.addWidget(tabs, 1) + + # Tab 1: Pipeline / Filters / Calibration + self._pip_tab = PipelineTab(self.registry, self.processor) + self._pip_tab.changed.connect(self.pipeline_changed) + tabs.addTab(self._pip_tab, " Pipeline ") + + # Tab 2: Derived channels + tabs.addTab(self._build_derived_tab(), " Derived ") + + # Tab 3: Channel visibility + self._vis_tab = ChannelVisTab(self.registry, self.processor) + self._vis_tab.changed.connect(self.visibility_changed) + tabs.addTab(self._vis_tab, " Channels ") + + def _build_derived_tab(self): + w = QWidget() + lay = QVBoxLayout(w); lay.setContentsMargins(0,0,0,0); lay.setSpacing(0) + + # Toolbar + top = QWidget(); top.setObjectName("cfgGlobalBar") + tl = QHBoxLayout(top); tl.setContentsMargins(10,7,10,7) + tl.addWidget(QLabel("New:")) + self._new_kind = QComboBox(); self._new_kind.setObjectName("channelPickerCb") + self._new_kind.addItems(_ALL_KINDS); tl.addWidget(self._new_kind) + cr = QPushButton("+ Create Derived Channel") + cr.setObjectName("addDeviceButton"); cr.clicked.connect(self._add_derived) + tl.addWidget(cr); tl.addStretch() + lay.addWidget(top) + + div = QFrame(); div.setFrameShape(QFrame.Shape.HLine) + div.setObjectName("devWindowDivider"); lay.addWidget(div) + + scroll = QScrollArea(); scroll.setWidgetResizable(True) + scroll.setObjectName("deviceScroll") + self._d_cont = QWidget() + self._d_lay = QVBoxLayout(self._d_cont) + self._d_lay.setContentsMargins(10,10,10,10); self._d_lay.setSpacing(10) + self._d_lay.addStretch() + scroll.setWidget(self._d_cont) + lay.addWidget(scroll, 1) + return w + + def _add_derived(self): + kind = self._new_kind.currentText() + color = _DERIVED_COLORS[self._color_idx % len(_DERIVED_COLORS)] + self._color_idx += 1 + n = len(self._derived_blocks) + 1 + dc = DerivedChannel(channel_id=f"sig_{n}", name=f"Signal {n}", + kind=kind, color=color) + blk = DerivedBlock(dc, self.registry, self.processor) + blk.removed.connect(self._rm_derived) + blk.applied.connect(lambda _: self.derived_changed.emit()) + self._derived_blocks.append(blk) + self._d_lay.insertWidget(self._d_lay.count()-1, blk) + + def _rm_derived(self, blk: DerivedBlock): + self.processor.remove_derived(blk.dc.channel_id) + self._derived_blocks.remove(blk) + self._d_lay.removeWidget(blk); blk.deleteLater() + self.derived_changed.emit() + + def closeEvent(self, e: QCloseEvent): + self.closed.emit(); e.accept() |
