summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChristian Kolset <christian.kolset@gmail.com>2026-04-20 16:55:57 -0600
committerChristian Kolset <christian.kolset@gmail.com>2026-04-20 16:55:57 -0600
commitd5acb04b88373d33b038bb59945fb5ab8b4f543b (patch)
tree21c37f2b54258ef20edc1b9b7226327d5f5b1a3a
parent425ba78ee1f760978b23a09fe8acbbc9b8b5dae4 (diff)
V8
-rw-r--r--api_layers/__pycache__/__init__.cpython-314.pycbin324 -> 327 bytes
-rw-r--r--api_layers/__pycache__/arduino_layer.cpython-314.pycbin15070 -> 15422 bytes
-rw-r--r--api_layers/__pycache__/nidaqmx_layer.cpython-314.pycbin10251 -> 10254 bytes
-rw-r--r--api_layers/arduino_layer.py337
-rw-r--r--core/__pycache__/__init__.cpython-314.pycbin277 -> 280 bytes
-rw-r--r--core/__pycache__/acquisition.cpython-314.pycbin14572 -> 13184 bytes
-rw-r--r--core/__pycache__/signal_processor.cpython-314.pycbin0 -> 29240 bytes
-rw-r--r--core/acquisition.py83
-rw-r--r--core/signal_processor.py464
-rw-r--r--devices/__pycache__/__init__.cpython-314.pycbin398 -> 401 bytes
-rw-r--r--devices/__pycache__/analog_input.cpython-314.pycbin16238 -> 24525 bytes
-rw-r--r--devices/__pycache__/base_device.cpython-314.pycbin7731 -> 7588 bytes
-rw-r--r--devices/__pycache__/device_registry.cpython-314.pycbin5927 -> 5930 bytes
-rw-r--r--devices/__pycache__/digital_io.cpython-314.pycbin19275 -> 19331 bytes
-rw-r--r--devices/__pycache__/serial_device.cpython-314.pycbin11824 -> 16431 bytes
-rw-r--r--devices/analog_input.py369
-rw-r--r--devices/base_device.py2
-rw-r--r--devices/digital_io.py3
-rw-r--r--devices/serial_device.py92
-rw-r--r--main.py16
-rw-r--r--plot.pngbin0 -> 111109 bytes
-rw-r--r--ui/__pycache__/__init__.cpython-314.pycbin153 -> 156 bytes
-rw-r--r--ui/__pycache__/config_dialog.cpython-314.pycbin7331 -> 7036 bytes
-rw-r--r--ui/__pycache__/control_panel.cpython-314.pycbin0 -> 34961 bytes
-rw-r--r--ui/__pycache__/main_window.cpython-314.pycbin15798 -> 24564 bytes
-rw-r--r--ui/__pycache__/strip_chart.cpython-314.pycbin11052 -> 16639 bytes
-rw-r--r--ui/add_device_dialog.py371
-rw-r--r--ui/config_dialog.py35
-rw-r--r--ui/control_panel.py559
-rw-r--r--ui/devices_window.py224
-rw-r--r--ui/main_window.py383
-rw-r--r--ui/plot_builder.py506
-rw-r--r--ui/plot_config.py629
-rw-r--r--ui/signal_builder.py605
-rw-r--r--ui/strip_chart.py329
-rw-r--r--ui/style.qss545
-rw-r--r--ui/style_dark.qss468
-rw-r--r--ui/style_light.qss69
-rw-r--r--ui/windows/__init__.py0
-rw-r--r--ui/windows/__pycache__/__init__.cpython-314.pycbin0 -> 164 bytes
-rw-r--r--ui/windows/__pycache__/devices_window.cpython-314.pycbin0 -> 23245 bytes
-rw-r--r--ui/windows/__pycache__/plot_window.cpython-314.pycbin0 -> 46125 bytes
-rw-r--r--ui/windows/__pycache__/settings_window.cpython-314.pycbin0 -> 16406 bytes
-rw-r--r--ui/windows/__pycache__/signals_window.cpython-314.pycbin0 -> 53434 bytes
-rw-r--r--ui/windows/devices_window.py319
-rw-r--r--ui/windows/plot_window.py486
-rw-r--r--ui/windows/settings_window.py231
-rw-r--r--ui/windows/signals_window.py659
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
index 88ad51b..fe19862 100644
--- a/api_layers/__pycache__/__init__.cpython-314.pyc
+++ b/api_layers/__pycache__/__init__.cpython-314.pyc
Binary files differ
diff --git a/api_layers/__pycache__/arduino_layer.cpython-314.pyc b/api_layers/__pycache__/arduino_layer.cpython-314.pyc
index 186b224..f01edd3 100644
--- a/api_layers/__pycache__/arduino_layer.cpython-314.pyc
+++ b/api_layers/__pycache__/arduino_layer.cpython-314.pyc
Binary files differ
diff --git a/api_layers/__pycache__/nidaqmx_layer.cpython-314.pyc b/api_layers/__pycache__/nidaqmx_layer.cpython-314.pyc
index 92f0ff3..084fe36 100644
--- a/api_layers/__pycache__/nidaqmx_layer.cpython-314.pyc
+++ b/api_layers/__pycache__/nidaqmx_layer.cpython-314.pyc
Binary files differ
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
index 2a7cb7e..e90d2a9 100644
--- a/core/__pycache__/__init__.cpython-314.pyc
+++ b/core/__pycache__/__init__.cpython-314.pyc
Binary files differ
diff --git a/core/__pycache__/acquisition.cpython-314.pyc b/core/__pycache__/acquisition.cpython-314.pyc
index d84b66b..c41e1a5 100644
--- a/core/__pycache__/acquisition.cpython-314.pyc
+++ b/core/__pycache__/acquisition.cpython-314.pyc
Binary files differ
diff --git a/core/__pycache__/signal_processor.cpython-314.pyc b/core/__pycache__/signal_processor.cpython-314.pyc
new file mode 100644
index 0000000..dc61203
--- /dev/null
+++ b/core/__pycache__/signal_processor.cpython-314.pyc
Binary files differ
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
index 9ab29a6..bbe1fd2 100644
--- a/devices/__pycache__/__init__.cpython-314.pyc
+++ b/devices/__pycache__/__init__.cpython-314.pyc
Binary files differ
diff --git a/devices/__pycache__/analog_input.cpython-314.pyc b/devices/__pycache__/analog_input.cpython-314.pyc
index 4c08631..27ce47c 100644
--- a/devices/__pycache__/analog_input.cpython-314.pyc
+++ b/devices/__pycache__/analog_input.cpython-314.pyc
Binary files differ
diff --git a/devices/__pycache__/base_device.cpython-314.pyc b/devices/__pycache__/base_device.cpython-314.pyc
index ade2b20..358f1ed 100644
--- a/devices/__pycache__/base_device.cpython-314.pyc
+++ b/devices/__pycache__/base_device.cpython-314.pyc
Binary files differ
diff --git a/devices/__pycache__/device_registry.cpython-314.pyc b/devices/__pycache__/device_registry.cpython-314.pyc
index 608c8ee..d4adce0 100644
--- a/devices/__pycache__/device_registry.cpython-314.pyc
+++ b/devices/__pycache__/device_registry.cpython-314.pyc
Binary files differ
diff --git a/devices/__pycache__/digital_io.cpython-314.pyc b/devices/__pycache__/digital_io.cpython-314.pyc
index b2bb27f..2fd4200 100644
--- a/devices/__pycache__/digital_io.cpython-314.pyc
+++ b/devices/__pycache__/digital_io.cpython-314.pyc
Binary files differ
diff --git a/devices/__pycache__/serial_device.cpython-314.pyc b/devices/__pycache__/serial_device.cpython-314.pyc
index 0674606..9ef9fca 100644
--- a/devices/__pycache__/serial_device.cpython-314.pyc
+++ b/devices/__pycache__/serial_device.cpython-314.pyc
Binary files differ
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),
)
diff --git a/main.py b/main.py
index 8e8d4e6..b023b99 100644
--- a/main.py
+++ b/main.py
@@ -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
new file mode 100644
index 0000000..1094f27
--- /dev/null
+++ b/plot.png
Binary files differ
diff --git a/ui/__pycache__/__init__.cpython-314.pyc b/ui/__pycache__/__init__.cpython-314.pyc
index 6e83590..0a61fd6 100644
--- a/ui/__pycache__/__init__.cpython-314.pyc
+++ b/ui/__pycache__/__init__.cpython-314.pyc
Binary files differ
diff --git a/ui/__pycache__/config_dialog.cpython-314.pyc b/ui/__pycache__/config_dialog.cpython-314.pyc
index 8876af3..f48f640 100644
--- a/ui/__pycache__/config_dialog.cpython-314.pyc
+++ b/ui/__pycache__/config_dialog.cpython-314.pyc
Binary files differ
diff --git a/ui/__pycache__/control_panel.cpython-314.pyc b/ui/__pycache__/control_panel.cpython-314.pyc
new file mode 100644
index 0000000..1e9e5a0
--- /dev/null
+++ b/ui/__pycache__/control_panel.cpython-314.pyc
Binary files differ
diff --git a/ui/__pycache__/main_window.cpython-314.pyc b/ui/__pycache__/main_window.cpython-314.pyc
index 27e4279..5b1082b 100644
--- a/ui/__pycache__/main_window.cpython-314.pyc
+++ b/ui/__pycache__/main_window.cpython-314.pyc
Binary files differ
diff --git a/ui/__pycache__/strip_chart.cpython-314.pyc b/ui/__pycache__/strip_chart.cpython-314.pyc
index 4af6ff5..27eef71 100644
--- a/ui/__pycache__/strip_chart.cpython-314.pyc
+++ b/ui/__pycache__/strip_chart.cpython-314.pyc
Binary files differ
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
new file mode 100644
index 0000000..ea15dd5
--- /dev/null
+++ b/ui/windows/__pycache__/__init__.cpython-314.pyc
Binary files differ
diff --git a/ui/windows/__pycache__/devices_window.cpython-314.pyc b/ui/windows/__pycache__/devices_window.cpython-314.pyc
new file mode 100644
index 0000000..88763e7
--- /dev/null
+++ b/ui/windows/__pycache__/devices_window.cpython-314.pyc
Binary files differ
diff --git a/ui/windows/__pycache__/plot_window.cpython-314.pyc b/ui/windows/__pycache__/plot_window.cpython-314.pyc
new file mode 100644
index 0000000..ad53019
--- /dev/null
+++ b/ui/windows/__pycache__/plot_window.cpython-314.pyc
Binary files differ
diff --git a/ui/windows/__pycache__/settings_window.cpython-314.pyc b/ui/windows/__pycache__/settings_window.cpython-314.pyc
new file mode 100644
index 0000000..0926445
--- /dev/null
+++ b/ui/windows/__pycache__/settings_window.cpython-314.pyc
Binary files differ
diff --git a/ui/windows/__pycache__/signals_window.cpython-314.pyc b/ui/windows/__pycache__/signals_window.cpython-314.pyc
new file mode 100644
index 0000000..4194ac8
--- /dev/null
+++ b/ui/windows/__pycache__/signals_window.cpython-314.pyc
Binary files differ
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()