summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--api_layers/__pycache__/__init__.cpython-312.pycbin0 -> 322 bytes
-rw-r--r--api_layers/__pycache__/__init__.cpython-314.pycbin327 -> 0 bytes
-rw-r--r--api_layers/__pycache__/arduino_layer.cpython-312.pycbin0 -> 30786 bytes
-rw-r--r--api_layers/__pycache__/arduino_layer.cpython-314.pycbin15422 -> 0 bytes
-rw-r--r--api_layers/__pycache__/nidaqmx_layer.cpython-312.pycbin0 -> 8512 bytes
-rw-r--r--api_layers/__pycache__/nidaqmx_layer.cpython-314.pycbin10254 -> 0 bytes
-rw-r--r--api_layers/arduino_layer.py561
-rw-r--r--core/__pycache__/__init__.cpython-312.pycbin0 -> 275 bytes
-rw-r--r--core/__pycache__/__init__.cpython-314.pycbin280 -> 0 bytes
-rw-r--r--core/__pycache__/acquisition.cpython-312.pycbin0 -> 11079 bytes
-rw-r--r--core/__pycache__/acquisition.cpython-314.pycbin13184 -> 0 bytes
-rw-r--r--core/__pycache__/profile.cpython-312.pycbin0 -> 11790 bytes
-rw-r--r--core/__pycache__/signal_processor.cpython-312.pycbin0 -> 23685 bytes
-rw-r--r--core/__pycache__/signal_processor.cpython-314.pycbin29240 -> 0 bytes
-rw-r--r--core/profile.py266
-rw-r--r--devices/__pycache__/__init__.cpython-312.pycbin0 -> 396 bytes
-rw-r--r--devices/__pycache__/__init__.cpython-314.pycbin401 -> 0 bytes
-rw-r--r--devices/__pycache__/analog_input.cpython-312.pycbin0 -> 23750 bytes
-rw-r--r--devices/__pycache__/analog_input.cpython-314.pycbin24525 -> 0 bytes
-rw-r--r--devices/__pycache__/base_device.cpython-312.pycbin0 -> 5615 bytes
-rw-r--r--devices/__pycache__/base_device.cpython-314.pycbin7588 -> 0 bytes
-rw-r--r--devices/__pycache__/device_registry.cpython-312.pycbin0 -> 4582 bytes
-rw-r--r--devices/__pycache__/device_registry.cpython-314.pycbin5930 -> 0 bytes
-rw-r--r--devices/__pycache__/digital_io.cpython-312.pycbin0 -> 18353 bytes
-rw-r--r--devices/__pycache__/digital_io.cpython-314.pycbin19331 -> 0 bytes
-rw-r--r--devices/__pycache__/serial_device.cpython-312.pycbin0 -> 14680 bytes
-rw-r--r--devices/__pycache__/serial_device.cpython-314.pycbin16431 -> 0 bytes
-rw-r--r--devices/analog_input.py26
-rw-r--r--devices/digital_io.py19
-rw-r--r--logs/daq_20260421_131953.csv366
-rw-r--r--plot.pngbin111109 -> 0 bytes
-rw-r--r--ui/__pycache__/__init__.cpython-312.pycbin0 -> 151 bytes
-rw-r--r--ui/__pycache__/__init__.cpython-314.pycbin156 -> 0 bytes
-rw-r--r--ui/__pycache__/add_device_dialog.cpython-314.pycbin23299 -> 0 bytes
-rw-r--r--ui/__pycache__/alarm_panel.cpython-314.pycbin6113 -> 0 bytes
-rw-r--r--ui/__pycache__/config_dialog.cpython-314.pycbin7036 -> 0 bytes
-rw-r--r--ui/__pycache__/control_editor.cpython-312.pycbin0 -> 20107 bytes
-rw-r--r--ui/__pycache__/control_panel.cpython-312.pycbin0 -> 39988 bytes
-rw-r--r--ui/__pycache__/control_panel.cpython-314.pycbin34961 -> 0 bytes
-rw-r--r--ui/__pycache__/device_panel.cpython-314.pycbin10002 -> 0 bytes
-rw-r--r--ui/__pycache__/main_window.cpython-312.pycbin0 -> 25316 bytes
-rw-r--r--ui/__pycache__/main_window.cpython-314.pycbin24564 -> 0 bytes
-rw-r--r--ui/__pycache__/profile_manager_ui.cpython-312.pycbin0 -> 8368 bytes
-rw-r--r--ui/__pycache__/readout_panel.cpython-314.pycbin9028 -> 0 bytes
-rw-r--r--ui/__pycache__/strip_chart.cpython-312.pycbin0 -> 14890 bytes
-rw-r--r--ui/__pycache__/strip_chart.cpython-314.pycbin16639 -> 0 bytes
-rw-r--r--ui/alarm_panel.py85
-rw-r--r--ui/control_editor.py319
-rw-r--r--ui/control_panel.py281
-rw-r--r--ui/main_window.py136
-rw-r--r--ui/profile_manager_ui.py152
-rw-r--r--ui/readout_panel.py122
-rw-r--r--ui/style_dark.qss29
-rw-r--r--ui/windows/__pycache__/__init__.cpython-312.pycbin0 -> 159 bytes
-rw-r--r--ui/windows/__pycache__/__init__.cpython-314.pycbin164 -> 0 bytes
-rw-r--r--ui/windows/__pycache__/devices_window.cpython-312.pycbin0 -> 22019 bytes
-rw-r--r--ui/windows/__pycache__/devices_window.cpython-314.pycbin23245 -> 0 bytes
-rw-r--r--ui/windows/__pycache__/plot_window.cpython-312.pycbin0 -> 44562 bytes
-rw-r--r--ui/windows/__pycache__/plot_window.cpython-314.pycbin46125 -> 0 bytes
-rw-r--r--ui/windows/__pycache__/settings_window.cpython-312.pycbin0 -> 15917 bytes
-rw-r--r--ui/windows/__pycache__/settings_window.cpython-314.pycbin16406 -> 0 bytes
-rw-r--r--ui/windows/__pycache__/signals_window.cpython-312.pycbin0 -> 51395 bytes
-rw-r--r--ui/windows/__pycache__/signals_window.cpython-314.pycbin53434 -> 0 bytes
64 files changed, 1929 insertions, 434 deletions
diff --git a/.gitignore b/.gitignore
deleted file mode 100644
index c75dc7c..0000000
--- a/.gitignore
+++ /dev/null
@@ -1 +0,0 @@
-logs/*
diff --git a/api_layers/__pycache__/__init__.cpython-312.pyc b/api_layers/__pycache__/__init__.cpython-312.pyc
new file mode 100644
index 0000000..8aa354b
--- /dev/null
+++ b/api_layers/__pycache__/__init__.cpython-312.pyc
Binary files differ
diff --git a/api_layers/__pycache__/__init__.cpython-314.pyc b/api_layers/__pycache__/__init__.cpython-314.pyc
deleted file mode 100644
index 01ed696..0000000
--- a/api_layers/__pycache__/__init__.cpython-314.pyc
+++ /dev/null
Binary files differ
diff --git a/api_layers/__pycache__/arduino_layer.cpython-312.pyc b/api_layers/__pycache__/arduino_layer.cpython-312.pyc
new file mode 100644
index 0000000..f6c3ff9
--- /dev/null
+++ b/api_layers/__pycache__/arduino_layer.cpython-312.pyc
Binary files differ
diff --git a/api_layers/__pycache__/arduino_layer.cpython-314.pyc b/api_layers/__pycache__/arduino_layer.cpython-314.pyc
deleted file mode 100644
index c1b7e5b..0000000
--- a/api_layers/__pycache__/arduino_layer.cpython-314.pyc
+++ /dev/null
Binary files differ
diff --git a/api_layers/__pycache__/nidaqmx_layer.cpython-312.pyc b/api_layers/__pycache__/nidaqmx_layer.cpython-312.pyc
new file mode 100644
index 0000000..de2c034
--- /dev/null
+++ b/api_layers/__pycache__/nidaqmx_layer.cpython-312.pyc
Binary files differ
diff --git a/api_layers/__pycache__/nidaqmx_layer.cpython-314.pyc b/api_layers/__pycache__/nidaqmx_layer.cpython-314.pyc
deleted file mode 100644
index e028b5e..0000000
--- a/api_layers/__pycache__/nidaqmx_layer.cpython-314.pyc
+++ /dev/null
Binary files differ
diff --git a/api_layers/arduino_layer.py b/api_layers/arduino_layer.py
index f2b8241..fb09a16 100644
--- a/api_layers/arduino_layer.py
+++ b/api_layers/arduino_layer.py
@@ -1,25 +1,66 @@
"""
api_layers/arduino_layer.py
-Arduino serial API layer.
+Arduino serial communication layer.
-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
+═══════════════════════════════════════════════════════════════
+PROTOCOL SPECIFICATION (v1.1)
+═══════════════════════════════════════════════════════════════
-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.
+PC → Arduino (commands, newline-terminated):
+─────────────────────────────────────────────
+ W:<pin>:<0|1> Digital write
+ W:D13:1 → digitalWrite(13, HIGH)
+ W:D6:0 → digitalWrite(6, LOW)
-Swap protocol by subclassing and overriding _parse_line().
+ P:<pin>:<0-255> PWM (analogWrite)
+ P:D9:128 → analogWrite(9, 128) ~50% duty
+
+ V:<pin>:<voltage> Analog voltage out (DAC, 0.0–5.0)
+ V:DAC0:2.5 → set DAC channel 0 to 2.5 V
+
+ S:<id>:<angle> Servo position (0–180 degrees)
+ S:SERVO0:90 → center servo 0
+
+ C:<name>:<value> Named setpoint / parameter
+ C:SETPOINT:75.0
+ C:KP:1.2
+ C:MODE:1
+
+ Q:<name> Request current reading by name (Arduino replies immediately)
+ Q:TEMP → Arduino sends TEMP:23.45\n
+
+ R:ALL Request full data frame immediately (don't wait for interval)
+
+ X:STOP Emergency stop — disable all outputs
+ X:RESET Reset all outputs to default state
+
+Arduino → PC (data, newline-terminated):
+─────────────────────────────────────────
+ <key>:<value>[,<key>:<value>...]\n Continuous stream
+ A0:3.142,A1:0.015,D2:1,TEMP:23.4\n
+
+ ACK:<command>\n Acknowledgement after command executed
+ ACK:W:D13:1\n
+ ACK:C:SETPOINT:75.0\n
+
+ ERR:<message>\n Error response
+ ERR:Unknown command\n
+
+═══════════════════════════════════════════════════════════════
+ARDUINO FIRMWARE (copy into Arduino IDE)
+═══════════════════════════════════════════════════════════════
+
+See ARDUINO_FIRMWARE string at the bottom of this file.
+Upload it to your board, set baud to 115200.
"""
import math
import random
+import json
import threading
import time
-from typing import Dict, List, Optional, Tuple
+from typing import Any, Callable, Dict, List, Optional, Tuple
try:
@@ -37,27 +78,32 @@ class ArduinoLayer:
def __init__(
self,
- port: str = "COM3",
- baud: int = 115200,
- timeout: float = 1.0,
- 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 # stored exactly as given — no override
+ self.simulate = simulate
+
+ self._ser: Optional[object] = None
+ self._cache: Dict[str, float] = {}
+ self._ack_cache: Dict[str, str] = {} # last ACK per command type
+ self._lock = threading.Lock()
+ self._running = False
+ self._thread: Optional[threading.Thread] = None
+ self._t0 = 0.0
+ self._last_error = ""
- 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
+ # Callbacks: registered by higher layers to receive ACK/ERR/data
+ self._ack_callbacks: List[Callable] = [] # fn(command: str)
+ self._err_callbacks: List[Callable] = [] # fn(message: str)
self._sim_params = {
pin: {
@@ -69,18 +115,12 @@ class ArduinoLayer:
}
for i, pin in enumerate(self.analog_pins)
}
+ # Simulated output state
+ self._sim_outputs: Dict[str, Any] = {}
# ── 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 = ""
@@ -102,7 +142,7 @@ class ArduinoLayer:
self._ser.port = self.port
self._ser.baudrate = self.baud
self._ser.timeout = self.timeout
- self._ser.open() # raises SerialException on failure
+ self._ser.open()
except Exception as e:
self._last_error = str(e)
print(f"[ArduinoLayer] connect() failed on {self.port}: {e}")
@@ -140,40 +180,135 @@ class ArduinoLayer:
def last_error(self) -> str:
return self._last_error
- # ── Read / Write ──────────────────────────────────────────────────────
+ # ── READ ──────────────────────────────────────────────────────────────
def read(self) -> Dict[str, float]:
+ """Return latest cached values for all channels."""
with self._lock:
return dict(self._cache)
+ # ── WRITE COMMANDS ────────────────────────────────────────────────────
+
+ def digital_write(self, pin: str, value: int) -> bool:
+ """
+ Set a digital pin HIGH or LOW.
+ pin: "D13", "D6", etc.
+ value: 1 = HIGH, 0 = LOW
+ Sends: W:D13:1\n
+ """
+ return self._send(f"W:{pin}:{int(bool(value))}")
+
+ def pwm_write(self, pin: str, duty: int) -> bool:
+ """
+ Set PWM duty cycle on a pin.
+ pin: "D9", "D10", "D11" (PWM-capable pins)
+ duty: 0–255 (0 = off, 255 = full on)
+ Sends: P:D9:128\n
+ """
+ duty = max(0, min(255, int(duty)))
+ return self._send(f"P:{pin}:{duty}")
+
+ def pwm_write_pct(self, pin: str, pct: float) -> bool:
+ """Convenience: PWM by percentage 0.0–100.0."""
+ return self.pwm_write(pin, int(pct / 100.0 * 255))
+
+ def analog_voltage(self, pin: str, voltage: float) -> bool:
+ """
+ Set analog output voltage (requires DAC, e.g. Arduino Due/Zero/MKR).
+ pin: "DAC0", "DAC1"
+ voltage: 0.0–5.0 V (or 0.0–3.3 V depending on board)
+ Sends: V:DAC0:2.500\n
+ """
+ return self._send(f"V:{pin}:{voltage:.3f}")
+
+ def servo_write(self, servo_id: str, angle: int) -> bool:
+ """
+ Set servo position.
+ servo_id: "SERVO0", "SERVO1", or "S0" etc.
+ angle: 0–180 degrees
+ Sends: S:SERVO0:90\n
+ """
+ angle = max(0, min(180, int(angle)))
+ return self._send(f"S:{servo_id}:{angle}")
+
+ def set_parameter(self, name: str, value: Any) -> bool:
+ """
+ Send a named setpoint or configuration parameter.
+ name: any string your firmware recognises, e.g. "SETPOINT", "KP", "MODE"
+ value: numeric or string
+ Sends: C:SETPOINT:75.000\n
+ """
+ if isinstance(value, float):
+ return self._send(f"C:{name}:{value:.4f}")
+ return self._send(f"C:{name}:{value}")
+
+ def request_value(self, name: str) -> bool:
+ """
+ Ask the Arduino to send the current value of a named sensor immediately.
+ Sends: Q:TEMP\n
+ Arduino replies: TEMP:23.45\n (parsed into cache automatically)
+ """
+ return self._send(f"Q:{name}")
+
+ def request_frame(self) -> bool:
+ """Ask the Arduino to send a full data frame immediately."""
+ return self._send("R:ALL")
+
+ def emergency_stop(self) -> bool:
+ """Disable all outputs immediately. Sends: X:STOP\n"""
+ return self._send("X:STOP")
+
+ def reset_outputs(self) -> bool:
+ """Reset all outputs to default state. Sends: X:RESET\n"""
+ return self._send("X:RESET")
+
+ # ── Legacy compat ─────────────────────────────────────────────────────
+
def write(self, pin: str, value: int) -> bool:
+ """
+ Backward-compatible write — calls digital_write().
+ Pin names starting with 'D' → digital.
+ Pin names starting with 'P' → PWM.
+ """
+ if pin.upper().startswith("P"):
+ return self.pwm_write(pin, value)
+ return self.digital_write(pin, value)
+
+ # ── Callbacks ─────────────────────────────────────────────────────────
+
+ def on_ack(self, callback: Callable[[str], None]):
+ """Register callback called when Arduino sends ACK:<command>."""
+ self._ack_callbacks.append(callback)
+
+ def on_error(self, callback: Callable[[str], None]):
+ """Register callback called when Arduino sends ERR:<message>."""
+ self._err_callbacks.append(callback)
+
+ # ── Internal send ──────────────────────────────────────────────────────
+
+ def _send(self, command: str) -> bool:
+ """Send a command string + newline to the Arduino."""
if self.simulate:
+ self._sim_handle_command(command)
return True
- if self._ser and self._ser.is_open:
- try:
- 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
+ if not (self._ser and self._ser.is_open):
+ return False
+ try:
+ self._ser.write(f"{command}\n".encode("utf-8"))
+ return True
+ except Exception as e:
+ print(f"[ArduinoLayer] send '{command}' failed: {e}")
+ return False
# ── Background threads ────────────────────────────────────────────────
def _read_loop(self):
- """
- 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
+ """Hardware read thread — waits for Arduino reset then reads continuously."""
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:
@@ -188,23 +323,20 @@ class ArduinoLayer:
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)
- consecutive_errors = 0
+ if line:
+ self._handle_line(line)
+ 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")
+ print(f"[ArduinoLayer] too many errors, stopping")
break
time.sleep(0.1)
def _sim_loop(self):
+ """Simulation thread — generates waveforms for analog pins."""
while self._running:
t = time.time() - self._t0
update = {}
@@ -217,55 +349,150 @@ class ArduinoLayer:
self._cache.update(update)
time.sleep(0.05)
- # ── Protocol ─────────────────────────────────────────────────────────
+ def _sim_handle_command(self, command: str):
+ """Simulate Arduino response to a command."""
+ with self._lock:
+ parts = command.split(":")
+ if len(parts) >= 3:
+ cmd_type = parts[0]
+ target = parts[1]
+ value = ":".join(parts[2:])
+ self._sim_outputs[target] = value
+ # Echo back as cached value for digital/pwm outputs
+ try:
+ self._cache[target] = float(value)
+ except ValueError:
+ pass
+ elif parts[0] == "X":
+ self._sim_outputs.clear()
+ # Fire ACK callbacks
+ for cb in self._ack_callbacks:
+ try:
+ cb(command)
+ except Exception:
+ pass
+
+ # ── Line parser ────────────────────────────────────────────────────────
+
+ def _handle_line(self, line: str):
+ """Route a line from the Arduino to cache, ACK, or ERR handlers."""
+ # ACK response
+ if line.startswith("ACK:"):
+ cmd = line[4:]
+ with self._lock:
+ self._ack_cache[cmd.split(":")[0]] = cmd
+ for cb in self._ack_callbacks:
+ try:
+ cb(cmd)
+ except Exception:
+ pass
+ return
+
+ # ERR response
+ if line.startswith("ERR:"):
+ msg = line[4:]
+ print(f"[Arduino ERR] {msg}")
+ for cb in self._err_callbacks:
+ try:
+ cb(msg)
+ except Exception:
+ pass
+ return
+
+ # Data line — parse into cache
+ parsed = self._parse_line(line)
+ if parsed:
+ with self._lock:
+ self._cache.update(parsed)
def _parse_line(self, line: str) -> Dict[str, float]:
"""
- 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
+ Parse Arduino serial output into {key: float}.
+
+ Handles all of these formats transparently:
+ Standard protocol: A0:3.142,A1:0.015
+ With unit suffix: A0:0.04 V,A1:1.23 mA
+ With line prefix: Analog:8, A0:0.04 V
+ Plain CSV: 3.142,0.015
+ JSON: {"A0":3.142,"A1":0.015}
+
+ Prefixes like "Analog:8" (where value is an integer channel index)
+ are recognised and skipped so they don't pollute the channel cache.
"""
line = line.strip()
- result: Dict[str, float] = {}
+ if not line:
+ return {}
+
+ # Store raw line for diagnostics (keep last 5)
+ with self._lock:
+ if not hasattr(self, '_raw_lines'):
+ self._raw_lines = []
+ self._raw_lines.append(line)
+ if len(self._raw_lines) > 5:
+ self._raw_lines.pop(0)
# JSON
if line.startswith("{"):
try:
- import json
- d = json.loads(line)
- return {k: float(v) for k, v in d.items()}
+ return {k: float(v) for k, v in json.loads(line).items()}
except Exception:
return {}
- # Key:value CSV
+ result: Dict[str, float] = {}
+ positional_idx = 0
+
for token in line.split(","):
token = token.strip()
if not token:
continue
+
if ":" in token:
- parts = token.split(":", 1)
+ key_raw, _, val_raw = token.partition(":")
+ key = key_raw.strip()
+ val_raw = val_raw.strip()
+
+ # Strip unit suffix — take only the first word (the number)
+ # "0.04 V" → "0.04", "1.23 mA" → "1.23", "3.14" → "3.14"
+ numeric_part = val_raw.split()[0] if val_raw else ""
+
try:
- result[parts[0].strip()] = float(parts[1].strip())
- except (ValueError, IndexError):
- pass
+ val = float(numeric_part)
+ except ValueError:
+ continue
+
+ # Skip prefix tokens where the "value" is actually a channel
+ # count or index, not a real measurement.
+ # Heuristic: key is a generic word (Analog, Digital, Chan, Ch)
+ # AND value is a small integer that looks like a count.
+ _SKIP_KEYS = {"analog", "digital", "chan", "channel", "ch",
+ "sensor", "input", "output", "port", "pin"}
+ if key.lower() in _SKIP_KEYS and val == int(val) and val < 32:
+ continue
+
+ result[key] = val
+
else:
- idx = len(result)
- if idx < len(self.analog_pins):
- try:
- result[self.analog_pins[idx]] = float(token)
- except ValueError:
- pass
+ # Plain positional value — map to analog_pins in order
+ try:
+ val = float(token.split()[0]) # strip any trailing unit
+ if positional_idx < len(self.analog_pins):
+ result[self.analog_pins[positional_idx]] = val
+ positional_idx += 1
+ except ValueError:
+ pass
+
return result
- # ── Utilities ─────────────────────────────────────────────────────────
+ @property
+ def raw_lines(self) -> list:
+ """Last few raw lines received — useful for diagnostics."""
+ with self._lock:
+ return list(getattr(self, "_raw_lines", []))
+
+ # ── Utilities ──────────────────────────────────────────────────────────
@staticmethod
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 []
try:
@@ -284,3 +511,171 @@ class ArduinoLayer:
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 — upload this to your board
+# ═══════════════════════════════════════════════════════════════════════════
+
+ARDUINO_FIRMWARE = r"""
+/*
+ * LabDAQ Arduino Firmware v1.1
+ * ─────────────────────────────────────────────────────────────────────────
+ * Upload this sketch to your Arduino.
+ * Set baud rate to 115200 in both this sketch and LabDAQ.
+ *
+ * WHAT IT DOES
+ * Continuously streams analog + digital readings to the PC.
+ * Listens for commands from the PC and executes them.
+ *
+ * RECEIVED COMMANDS (PC → Arduino):
+ * W:D13:1 digitalWrite(13, HIGH)
+ * W:D6:0 digitalWrite(6, LOW)
+ * P:D9:128 analogWrite(9, 128) → ~50% PWM
+ * C:SETPOINT:75.0 store named parameter
+ * Q:A0 reply immediately with A0 reading
+ * R:ALL send full data frame immediately
+ * X:STOP set all outputs LOW
+ * X:RESET reset to defaults
+ *
+ * SENT DATA (Arduino → PC):
+ * A0:3.142,A1:0.015,D2:0,D3:1\n (every SEND_INTERVAL ms)
+ * ACK:W:D13:1\n (after each command)
+ * ERR:Unknown command\n (on parse failure)
+ * ─────────────────────────────────────────────────────────────────────────
+ */
+
+// ── Configuration ─────────────────────────────────────────────────────────
+const int ANALOG_PINS[] = {A0, A1, A2, A3, A4, A5};
+const int DIGITAL_IN[] = {2, 3, 4};
+const int DIGITAL_OUT[] = {5, 6, 7, 9, 10, 11}; // 9,10,11 are PWM-capable
+const int N_ANALOG = 1; // ← set to how many analog pins you use
+const int N_DIG_IN = 0; // ← set to how many digital inputs you use
+const int N_DIG_OUT = 0; // ← set to how many digital outputs you use
+const int SEND_INTERVAL = 50; // ms between data frames (50 = 20 Hz)
+const long BAUD_RATE = 115200;
+
+// ── Named parameters (set via C: commands) ──────────────────────────────
+float param_setpoint = 0.0;
+float param_kp = 1.0;
+float param_ki = 0.0;
+float param_kd = 0.0;
+int param_mode = 0;
+
+unsigned long lastSend = 0;
+
+// ── Setup ─────────────────────────────────────────────────────────────────
+void setup() {
+ Serial.begin(BAUD_RATE);
+ 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);
+}
+
+// ── Main loop ─────────────────────────────────────────────────────────────
+void loop() {
+ handleCommands();
+ sendData();
+}
+
+// ── Command handler ───────────────────────────────────────────────────────
+void handleCommands() {
+ if (!Serial.available()) return;
+
+ String cmd = Serial.readStringUntil('\n');
+ cmd.trim();
+ if (cmd.length() == 0) return;
+
+ char type = cmd.charAt(0);
+
+ // W:Dxx:val — digital write
+ if (type == 'W') {
+ int c1 = cmd.indexOf(':', 2);
+ if (c1 < 0) { Serial.println("ERR:Bad W format"); return; }
+ String pinStr = cmd.substring(2, c1);
+ int val = cmd.substring(c1 + 1).toInt();
+ int pin = pinStr.substring(1).toInt(); // strip 'D'
+ digitalWrite(pin, val ? HIGH : LOW);
+ Serial.print("ACK:"); Serial.println(cmd);
+ }
+
+ // P:Dxx:duty — PWM write (0-255)
+ else if (type == 'P') {
+ int c1 = cmd.indexOf(':', 2);
+ if (c1 < 0) { Serial.println("ERR:Bad P format"); return; }
+ int pin = cmd.substring(2, c1).substring(1).toInt();
+ int duty = constrain(cmd.substring(c1 + 1).toInt(), 0, 255);
+ analogWrite(pin, duty);
+ Serial.print("ACK:"); Serial.println(cmd);
+ }
+
+ // C:NAME:value — set named parameter
+ else if (type == 'C') {
+ int c1 = cmd.indexOf(':', 2);
+ int c2 = cmd.indexOf(':', c1 + 1);
+ if (c1 < 0 || c2 < 0) { Serial.println("ERR:Bad C format"); return; }
+ String name = cmd.substring(2, c1);
+ float val = cmd.substring(c2 + 1).toFloat();
+ if (name == "SETPOINT") param_setpoint = val;
+ else if (name == "KP") param_kp = val;
+ else if (name == "KI") param_ki = val;
+ else if (name == "KD") param_kd = val;
+ else if (name == "MODE") param_mode = (int)val;
+ // Add your own parameters here:
+ // else if (name == "SPEED") motor_speed = val;
+ Serial.print("ACK:"); Serial.println(cmd);
+ }
+
+ // Q:NAME — immediate query
+ else if (type == 'Q') {
+ String name = cmd.substring(2);
+ if (name == "SETPOINT") { Serial.print("SETPOINT:"); Serial.println(param_setpoint, 3); }
+ else if (name == "KP") { Serial.print("KP:"); Serial.println(param_kp, 4); }
+ else {
+ // Try to read as analog pin Q:A0
+ if (name.charAt(0) == 'A') {
+ int pin = name.substring(1).toInt();
+ float v = analogRead(pin) * (5.0 / 1023.0);
+ Serial.print(name); Serial.print(":"); Serial.println(v, 3);
+ }
+ }
+ }
+
+ // R:ALL — send full frame immediately
+ else if (type == 'R') {
+ sendFrame();
+ }
+
+ // X:STOP / X:RESET — emergency stop
+ else if (type == 'X') {
+ String sub = cmd.substring(2);
+ for (int i = 0; i < N_DIG_OUT; i++) digitalWrite(DIGITAL_OUT[i], LOW);
+ Serial.print("ACK:"); Serial.println(cmd);
+ }
+
+ else {
+ Serial.print("ERR:Unknown command: "); Serial.println(cmd);
+ }
+}
+
+// ── Data sender ───────────────────────────────────────────────────────────
+void sendData() {
+ if (millis() - lastSend < SEND_INTERVAL) return;
+ lastSend = millis();
+ sendFrame();
+}
+
+void sendFrame() {
+ String out = "";
+ // Analog inputs — converted to 0.0–5.0 V
+ for (int i = 0; i < N_ANALOG; i++) {
+ float v = analogRead(ANALOG_PINS[i]) * (5.0 / 1023.0);
+ if (i > 0) out += ",";
+ out += "A" + String(i) + ":" + String(v, 3);
+ }
+ // Digital inputs (INPUT_PULLUP — invert so pressed=1)
+ 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-312.pyc b/core/__pycache__/__init__.cpython-312.pyc
new file mode 100644
index 0000000..36a9dda
--- /dev/null
+++ b/core/__pycache__/__init__.cpython-312.pyc
Binary files differ
diff --git a/core/__pycache__/__init__.cpython-314.pyc b/core/__pycache__/__init__.cpython-314.pyc
deleted file mode 100644
index 48b27f8..0000000
--- a/core/__pycache__/__init__.cpython-314.pyc
+++ /dev/null
Binary files differ
diff --git a/core/__pycache__/acquisition.cpython-312.pyc b/core/__pycache__/acquisition.cpython-312.pyc
new file mode 100644
index 0000000..3d036ae
--- /dev/null
+++ b/core/__pycache__/acquisition.cpython-312.pyc
Binary files differ
diff --git a/core/__pycache__/acquisition.cpython-314.pyc b/core/__pycache__/acquisition.cpython-314.pyc
deleted file mode 100644
index 023a11f..0000000
--- a/core/__pycache__/acquisition.cpython-314.pyc
+++ /dev/null
Binary files differ
diff --git a/core/__pycache__/profile.cpython-312.pyc b/core/__pycache__/profile.cpython-312.pyc
new file mode 100644
index 0000000..8637365
--- /dev/null
+++ b/core/__pycache__/profile.cpython-312.pyc
Binary files differ
diff --git a/core/__pycache__/signal_processor.cpython-312.pyc b/core/__pycache__/signal_processor.cpython-312.pyc
new file mode 100644
index 0000000..fe7bba0
--- /dev/null
+++ b/core/__pycache__/signal_processor.cpython-312.pyc
Binary files differ
diff --git a/core/__pycache__/signal_processor.cpython-314.pyc b/core/__pycache__/signal_processor.cpython-314.pyc
deleted file mode 100644
index 4b1ee4e..0000000
--- a/core/__pycache__/signal_processor.cpython-314.pyc
+++ /dev/null
Binary files differ
diff --git a/core/profile.py b/core/profile.py
new file mode 100644
index 0000000..b2d25b4
--- /dev/null
+++ b/core/profile.py
@@ -0,0 +1,266 @@
+"""
+core/profile.py
+
+Lab profile — saves and loads the complete operator configuration:
+
+ • Controls — all control widgets (type, title, device, channel, params)
+ • Channels — per-channel visibility, name overrides
+ • Signal pipelines — filter stacks per channel
+ • Derived channels — all virtual/computed channels
+ • Plot layout — pane arrangement, traces, axes, time window
+
+Profiles are stored as human-readable JSON files (.labdaq).
+"""
+
+from __future__ import annotations
+import json
+import os
+from dataclasses import dataclass, field, asdict
+from typing import Any, Dict, List, Optional
+
+
+# ── Helpers ───────────────────────────────────────────────────────────────────
+
+def _safe(d: dict, key: str, default=None):
+ return d.get(key, default)
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# Profile data model
+# ══════════════════════════════════════════════════════════════════════════════
+
+@dataclass
+class ProfileChannelOverride:
+ """Per-channel settings that survive device reconnects."""
+ device_id: str
+ channel_id: str
+ name: str = ""
+ unit: str = ""
+ enabled: bool = True
+ color: str = ""
+
+
+@dataclass
+class ProfilePipeline:
+ """Serialised filter pipeline for one channel."""
+ device_id: str
+ channel_id: str
+ enabled: bool = True
+ filters: List[Dict[str, Any]] = field(default_factory=list)
+ # filters = [{"type": "low_pass", "alpha": 0.1}, ...]
+
+
+@dataclass
+class ProfileDerived:
+ """Serialised derived/virtual channel."""
+ channel_id: str
+ name: str
+ unit: str = ""
+ color: str = "#f72585"
+ kind: str = "expression"
+ sources: List = field(default_factory=list) # [(dev_id, ch_id), ...]
+ expression: str = ""
+ script: str = ""
+ params: Dict = field(default_factory=dict)
+ enabled: bool = True
+
+
+@dataclass
+class Profile:
+ name: str = "Untitled Profile"
+ version: str = "1.0"
+ controls: List[Dict] = field(default_factory=list)
+ channels: List[Dict] = field(default_factory=list)
+ pipelines: List[Dict] = field(default_factory=list)
+ derived: List[Dict] = field(default_factory=list)
+ plot: Optional[Dict] = None # LayoutConfig.to_json()
+ settings: Dict[str, Any] = field(default_factory=dict)
+
+ def to_json(self) -> str:
+ return json.dumps(asdict(self), indent=2)
+
+ @staticmethod
+ def from_json(s: str) -> "Profile":
+ d = json.loads(s)
+ return Profile(
+ name = d.get("name", ""),
+ version = d.get("version", "1.0"),
+ controls = d.get("controls", []),
+ channels = d.get("channels", []),
+ pipelines = d.get("pipelines", []),
+ derived = d.get("derived", []),
+ plot = d.get("plot"),
+ settings = d.get("settings", {}),
+ )
+
+ def save(self, path: str):
+ os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
+ with open(path, "w") as f:
+ f.write(self.to_json())
+
+ @staticmethod
+ def load(path: str) -> "Profile":
+ with open(path) as f:
+ return Profile.from_json(f.read())
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# ProfileManager — serialises / deserialises the live app state
+# ══════════════════════════════════════════════════════════════════════════════
+
+class ProfileManager:
+ """
+ Converts between a Profile dict and the live application objects.
+
+ Usage:
+ # Save
+ profile = ProfileManager.capture(registry, processor, chart_cfg,
+ control_specs, settings)
+ profile.save("my_lab.labdaq")
+
+ # Load
+ profile = Profile.load("my_lab.labdaq")
+ ProfileManager.apply(profile, registry, processor, ...)
+ """
+
+ # ── Capture ──────────────────────────────────────────────────────────────
+
+ @staticmethod
+ def capture(
+ registry,
+ processor,
+ plot_cfg,
+ control_specs: List,
+ settings: dict,
+ profile_name: str = "Profile",
+ ) -> Profile:
+ p = Profile(name=profile_name)
+
+ # Controls
+ p.controls = [spec.to_dict() for spec in control_specs]
+
+ # Channel overrides
+ for dev in registry.all_instances():
+ for ch in dev.info.channels:
+ p.channels.append({
+ "device_id": dev.info.device_id,
+ "channel_id": ch.channel_id,
+ "name": ch.name,
+ "unit": ch.unit,
+ "enabled": ch.enabled,
+ "color": ch.color,
+ })
+
+ # Signal pipelines
+ for key, pipeline in processor._pipelines.items():
+ p.pipelines.append({
+ "device_id": pipeline.device_id,
+ "channel_id": pipeline.channel_id,
+ "enabled": pipeline.enabled,
+ "filters": [f.to_dict() for f in pipeline.filters],
+ })
+
+ # Derived channels
+ for dc in processor.get_derived():
+ p.derived.append({
+ "channel_id": dc.channel_id,
+ "name": dc.name,
+ "unit": dc.unit,
+ "color": dc.color,
+ "kind": dc.kind,
+ "sources": [list(s) for s in dc.sources],
+ "expression": dc.expression,
+ "script": dc.script,
+ "params": {k: v for k, v in dc.params.items()
+ if not k.startswith("_")},
+ "enabled": dc.enabled,
+ })
+
+ # Plot layout
+ if plot_cfg is not None:
+ try:
+ from ui.windows.plot_window import LayoutConfig
+ if hasattr(plot_cfg, "to_json"):
+ p.plot = json.loads(plot_cfg.to_json())
+ except Exception:
+ pass
+
+ p.settings = dict(settings)
+ return p
+
+ # ── Apply ─────────────────────────────────────────────────────────────────
+
+ @staticmethod
+ def apply(
+ profile: Profile,
+ registry,
+ processor,
+ control_panel,
+ settings_ref: dict,
+ ):
+ """Apply a loaded profile to the live application state."""
+ from ui.control_editor import ControlSpec
+ from core.signal_processor import (
+ ChannelPipeline, DerivedChannel,
+ filter_from_dict,
+ )
+
+ # ── Channel overrides ────────────────────────────────────────────
+ for ch_data in profile.channels:
+ dev = registry.get_instance(ch_data["device_id"])
+ if not dev:
+ continue
+ ch = dev.get_channel(ch_data["channel_id"])
+ if not ch:
+ continue
+ ch.name = ch_data.get("name", ch.name)
+ ch.unit = ch_data.get("unit", ch.unit)
+ ch.enabled = ch_data.get("enabled", ch.enabled)
+ if ch_data.get("color"):
+ ch.color = ch_data["color"]
+
+ # ── Signal pipelines ─────────────────────────────────────────────
+ for pd in profile.pipelines:
+ filters = [filter_from_dict(fd) for fd in pd.get("filters", [])]
+ pipeline = ChannelPipeline(
+ device_id=pd["device_id"],
+ channel_id=pd["channel_id"],
+ filters=filters,
+ enabled=pd.get("enabled", True),
+ )
+ processor.set_pipeline(pipeline)
+
+ # ── Derived channels ─────────────────────────────────────────────
+ for dd in profile.derived:
+ sources = [tuple(s) for s in dd.get("sources", [])]
+ dc = DerivedChannel(
+ channel_id=dd["channel_id"],
+ name=dd.get("name", dd["channel_id"]),
+ unit=dd.get("unit", ""),
+ color=dd.get("color", "#f72585"),
+ kind=dd.get("kind", "expression"),
+ sources=sources,
+ expression=dd.get("expression", ""),
+ script=dd.get("script", ""),
+ params=dd.get("params", {}),
+ enabled=dd.get("enabled", True),
+ )
+ processor.add_derived(dc)
+
+ # ── Controls ─────────────────────────────────────────────────────
+ specs = [ControlSpec.from_dict(d) for d in profile.controls]
+ control_panel.load_specs(specs)
+
+ # ── Settings ─────────────────────────────────────────────────────
+ settings_ref.update(profile.settings)
+
+ # ── Plot layout ───────────────────────────────────────────────────
+ plot_cfg = None
+ if profile.plot:
+ try:
+ from ui.windows.plot_window import LayoutConfig
+ plot_cfg = LayoutConfig.from_json(json.dumps(profile.plot))
+ except Exception as e:
+ print(f"[Profile] Could not restore plot layout: {e}")
+
+ return plot_cfg # caller applies this to the chart
diff --git a/devices/__pycache__/__init__.cpython-312.pyc b/devices/__pycache__/__init__.cpython-312.pyc
new file mode 100644
index 0000000..bd9bb71
--- /dev/null
+++ b/devices/__pycache__/__init__.cpython-312.pyc
Binary files differ
diff --git a/devices/__pycache__/__init__.cpython-314.pyc b/devices/__pycache__/__init__.cpython-314.pyc
deleted file mode 100644
index 47d46e4..0000000
--- a/devices/__pycache__/__init__.cpython-314.pyc
+++ /dev/null
Binary files differ
diff --git a/devices/__pycache__/analog_input.cpython-312.pyc b/devices/__pycache__/analog_input.cpython-312.pyc
new file mode 100644
index 0000000..2ff8a31
--- /dev/null
+++ b/devices/__pycache__/analog_input.cpython-312.pyc
Binary files differ
diff --git a/devices/__pycache__/analog_input.cpython-314.pyc b/devices/__pycache__/analog_input.cpython-314.pyc
deleted file mode 100644
index b6be747..0000000
--- a/devices/__pycache__/analog_input.cpython-314.pyc
+++ /dev/null
Binary files differ
diff --git a/devices/__pycache__/base_device.cpython-312.pyc b/devices/__pycache__/base_device.cpython-312.pyc
new file mode 100644
index 0000000..f665ff1
--- /dev/null
+++ b/devices/__pycache__/base_device.cpython-312.pyc
Binary files differ
diff --git a/devices/__pycache__/base_device.cpython-314.pyc b/devices/__pycache__/base_device.cpython-314.pyc
deleted file mode 100644
index 6566690..0000000
--- a/devices/__pycache__/base_device.cpython-314.pyc
+++ /dev/null
Binary files differ
diff --git a/devices/__pycache__/device_registry.cpython-312.pyc b/devices/__pycache__/device_registry.cpython-312.pyc
new file mode 100644
index 0000000..2694da3
--- /dev/null
+++ b/devices/__pycache__/device_registry.cpython-312.pyc
Binary files differ
diff --git a/devices/__pycache__/device_registry.cpython-314.pyc b/devices/__pycache__/device_registry.cpython-314.pyc
deleted file mode 100644
index 780bd71..0000000
--- a/devices/__pycache__/device_registry.cpython-314.pyc
+++ /dev/null
Binary files differ
diff --git a/devices/__pycache__/digital_io.cpython-312.pyc b/devices/__pycache__/digital_io.cpython-312.pyc
new file mode 100644
index 0000000..8c7375b
--- /dev/null
+++ b/devices/__pycache__/digital_io.cpython-312.pyc
Binary files differ
diff --git a/devices/__pycache__/digital_io.cpython-314.pyc b/devices/__pycache__/digital_io.cpython-314.pyc
deleted file mode 100644
index c1a14b4..0000000
--- a/devices/__pycache__/digital_io.cpython-314.pyc
+++ /dev/null
Binary files differ
diff --git a/devices/__pycache__/serial_device.cpython-312.pyc b/devices/__pycache__/serial_device.cpython-312.pyc
new file mode 100644
index 0000000..7472878
--- /dev/null
+++ b/devices/__pycache__/serial_device.cpython-312.pyc
Binary files differ
diff --git a/devices/__pycache__/serial_device.cpython-314.pyc b/devices/__pycache__/serial_device.cpython-314.pyc
deleted file mode 100644
index 46fb630..0000000
--- a/devices/__pycache__/serial_device.cpython-314.pyc
+++ /dev/null
Binary files differ
diff --git a/devices/analog_input.py b/devices/analog_input.py
index 6183f43..508804f 100644
--- a/devices/analog_input.py
+++ b/devices/analog_input.py
@@ -141,7 +141,22 @@ class AnalogInputDevice(BaseDevice):
return self._layer.read()
def write_channel(self, channel_id: str, value: Any) -> bool:
- return False
+ """
+ Route output commands through the Arduino layer.
+
+ channel_id examples:
+ "D7" → digital write W:D7:1 or W:D7:0
+ "D9" → PWM write P:D9:<duty> (if value is 0-255)
+ "HEAT" → named param C:HEAT:1.0
+ """
+ if self.backend != "arduino" or not hasattr(self._layer, "digital_write"):
+ return False
+ ch = channel_id.strip()
+ # Digital pin (D7, D13, etc.)
+ if ch.upper().startswith("D") and ch[1:].isdigit():
+ return self._layer.digital_write(ch, int(bool(value)))
+ # Named parameter / setpoint
+ return self._layer.set_parameter(ch, value)
def get_config_widget(self) -> QWidget:
return AnalogInputConfigWidget(self)
@@ -295,7 +310,7 @@ class AnalogInputConfigWidget(QWidget):
self._diag_box = QTextEdit()
self._diag_box.setObjectName("codeEditor")
self._diag_box.setReadOnly(True)
- self._diag_box.setMaximumHeight(90)
+ self._diag_box.setMaximumHeight(160)
self._diag_box.setPlaceholderText("Connection log will appear here…")
diag_lay.addWidget(self._diag_box)
@@ -332,6 +347,13 @@ class AnalogInputConfigWidget(QWidget):
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'}")
+ # Show last raw lines received from Arduino
+ if hasattr(self.device._layer, "raw_lines"):
+ raw = self.device._layer.raw_lines
+ if raw:
+ lines.append("\nLast lines from Arduino:")
+ for r in raw:
+ lines.append(f" {r}")
if self.device._last_error:
lines.append(f"\nError:\n{self.device._last_error}")
self._diag_box.setPlainText("\n".join(lines))
diff --git a/devices/digital_io.py b/devices/digital_io.py
index 291baea..53014b5 100644
--- a/devices/digital_io.py
+++ b/devices/digital_io.py
@@ -206,9 +206,26 @@ class DigitalIODevice(BaseDevice):
except Exception as e:
print(f"[DigitalIODevice] NI write failed: {e}")
elif self.backend == "arduino" and self._ard_layer:
- self._ard_layer.write(channel_id, int(bool(value)))
+ # Map channel ID to Arduino pin name:
+ # do0→D5, do1→D6, do2→D7... (matches DIGITAL_OUT in firmware)
+ # Or the channel_id itself if it already looks like D5
+ pin = channel_id if channel_id.upper().startswith("D") else f"D{5 + int(channel_id.replace('do',''))}"
+ self._ard_layer.digital_write(pin, int(bool(value)))
return True
+ def pwm_channel(self, channel_id: str, duty_pct: float) -> bool:
+ """Send a PWM command to an Arduino output pin (0–100%)."""
+ if self.backend == "arduino" and self._ard_layer and not self.simulate:
+ pin = channel_id if channel_id.upper().startswith("D") else f"D{9 + int(channel_id.replace('do',''))}"
+ return self._ard_layer.pwm_write_pct(pin, duty_pct)
+ return False
+
+ def set_parameter(self, name: str, value: Any) -> bool:
+ """Send a named parameter/setpoint to the Arduino."""
+ if self.backend == "arduino" and self._ard_layer and not self.simulate:
+ return self._ard_layer.set_parameter(name, value)
+ return False
+
def get_config_widget(self) -> QWidget:
return DigitalIOConfigWidget(self)
diff --git a/logs/daq_20260421_131953.csv b/logs/daq_20260421_131953.csv
new file mode 100644
index 0000000..e856dac
--- /dev/null
+++ b/logs/daq_20260421_131953.csv
@@ -0,0 +1,366 @@
+elapsed_s,dio_0/di0[],dio_0/do0[],dio_0/do1[],dio_0/do2[],dio_0/do3[],ai_0/A0[V]
+21.7493,,,,,,0.00000
+21.8494,,,,,,0.00000
+21.9498,,,,,,0.00000
+22.0500,,,,,,0.00000
+22.1503,,,,,,0.00000
+22.2506,,,,,,0.00000
+22.3509,,,,,,0.00000
+22.4512,,,,,,0.00000
+22.5516,,,,,,0.00000
+22.6519,,,,,,0.00000
+22.7521,,,,,,0.00000
+22.8524,,,,,,0.00000
+22.9527,,,,,,0.00000
+23.0529,,,,,,0.00000
+23.1532,,,,,,0.00000
+23.2534,,,,,,0.00000
+23.3537,,,,,,0.00000
+23.4540,,,,,,0.00000
+23.5541,,,,,,0.00000
+23.6545,,,,,,0.00000
+23.7548,,,,,,0.00000
+23.8551,,,,,,0.00000
+23.9555,,,,,,0.07000
+24.0558,,,,,,0.30000
+24.1561,,,,,,0.51000
+24.2563,,,,,,0.64000
+24.3566,,,,,,0.78000
+24.4570,,,,,,0.95000
+24.5573,,,,,,1.15000
+24.6576,,,,,,1.30000
+24.7580,,,,,,1.56000
+24.8581,,,,,,1.62000
+24.9585,,,,,,1.64000
+25.0588,,,,,,1.95000
+25.1592,,,,,,2.05000
+25.2592,,,,,,2.14000
+25.3595,,,,,,2.29000
+25.4596,,,,,,2.52000
+25.5599,,,,,,2.69000
+25.6601,,,,,,2.86000
+25.7601,,,,,,3.07000
+25.8602,,,,,,3.50000
+25.9603,,,,,,3.73000
+26.0605,,,,,,3.91000
+26.1607,,,,,,3.89000
+26.2609,,,,,,3.05000
+26.3610,,,,,,2.63000
+26.4613,,,,,,2.57000
+26.5614,,,,,,3.06000
+26.6616,,,,,,3.46000
+26.7618,,,,,,3.75000
+26.8619,,,,,,3.84000
+26.9623,,,,,,3.89000
+27.0626,,,,,,3.17000
+27.1628,,,,,,2.92000
+27.2629,,,,,,3.02000
+27.3633,,,,,,3.83000
+27.4636,,,,,,4.46000
+27.5637,,,,,,4.46000
+27.6639,,,,,,3.68000
+27.7641,,,,,,3.30000
+27.8644,,,,,,3.04000
+27.9646,,,,,,3.03000
+28.0649,,,,,,3.79000
+28.1653,,,,,,3.84000
+28.2656,,,,,,3.89000
+28.3659,,,,,,3.89000
+28.4662,,,,,,3.22000
+28.5666,,,,,,3.23000
+28.6669,,,,,,3.23000
+28.7670,,,,,,3.28000
+28.8674,,,,,,3.50000
+28.9678,,,,,,3.78000
+29.0681,,,,,,3.65000
+29.1684,,,,,,3.54000
+29.2686,,,,,,4.43000
+29.3689,,,,,,4.27000
+29.4693,,,,,,3.48000
+29.5696,,,,,,3.11000
+29.6700,,,,,,3.22000
+29.7703,,,,,,3.75000
+29.8706,,,,,,3.90000
+29.9710,,,,,,3.67000
+30.0713,,,,,,3.56000
+30.1716,,,,,,3.52000
+30.2719,,,,,,3.63000
+30.3720,,,,,,3.64000
+30.4723,,,,,,3.75000
+30.5727,,,,,,3.33000
+30.6730,,,,,,3.16000
+30.7734,,,,,,3.17000
+30.8737,,,,,,3.75000
+30.9738,,,,,,3.84000
+31.0742,,,,,,3.37000
+31.1745,,,,,,3.24000
+31.2747,,,,,,3.75000
+31.3748,,,,,,3.60000
+31.4750,,,,,,3.46000
+31.5752,,,,,,3.15000
+31.6752,,,,,,3.03000
+31.7753,,,,,,3.87000
+31.8756,,,,,,3.88000
+31.9756,,,,,,3.22000
+32.0758,,,,,,3.03000
+32.1760,,,,,,3.21000
+32.2761,,,,,,3.59000
+32.3762,,,,,,3.88000
+32.4764,,,,,,4.01000
+32.5764,,,,,,4.12000
+32.6765,,,,,,4.35000
+32.7767,,,,,,4.39000
+32.8769,,,,,,4.57000
+32.9771,,,,,,4.77000
+33.0773,,,,,,4.72000
+33.1773,,,,,,4.73000
+33.2775,,,,,,4.83000
+33.3778,,,,,,4.84000
+33.4779,,,,,,4.87000
+33.5781,,,,,,4.88000
+33.6782,,,,,,4.84000
+33.7783,,,,,,4.96000
+33.8785,,,,,,4.91000
+33.9787,,,,,,4.84000
+34.0789,,,,,,4.95000
+34.1793,,,,,,4.93000
+34.2795,,,,,,4.85000
+34.3799,,,,,,4.73000
+34.4802,,,,,,4.85000
+34.5805,,,,,,5.00000
+34.6806,,,,,,5.00000
+34.7808,,,,,,4.98000
+34.8810,,,,,,4.34000
+34.9811,,,,,,4.34000
+35.0812,,,,,,4.93000
+35.1814,,,,,,3.41000
+35.2815,,,,,,4.98000
+35.3818,,,,,,4.99000
+35.4820,,,,,,5.00000
+35.5820,,,,,,4.99000
+35.6821,,,,,,4.98000
+35.7825,,,,,,4.98000
+35.8829,,,,,,4.98000
+35.9832,,,,,,4.99000
+36.0836,,,,,,4.99000
+36.1839,,,,,,4.99000
+36.2842,,,,,,4.99000
+36.3843,,,,,,4.99000
+36.4845,,,,,,4.97000
+36.5848,,,,,,4.99000
+36.6850,,,,,,4.99000
+36.7854,,,,,,5.00000
+36.8856,,,,,,4.38000
+36.9857,,,,,,4.90000
+37.0859,,,,,,4.89000
+37.1862,,,,,,4.89000
+37.2863,,,,,,4.88000
+37.3865,,,,,,4.88000
+37.4867,,,,,,4.88000
+37.5870,,,,,,4.87000
+37.6874,,,,,,4.17000
+37.7877,,,,,,4.96000
+37.8879,,,,,,4.89000
+37.9881,,,,,,4.93000
+38.0885,,,,,,4.89000
+38.1887,,,,,,4.96000
+38.2890,,,,,,4.96000
+38.3893,,,,,,4.95000
+38.4897,,,,,,4.98000
+38.5901,,,,,,4.99000
+38.6904,,,,,,4.99000
+38.7906,,,,,,5.00000
+38.8910,,,,,,4.98000
+38.9914,,,,,,5.00000
+39.0917,,,,,,4.99000
+39.1921,,,,,,4.98000
+39.2923,,,,,,4.91000
+39.3925,,,,,,4.69000
+39.4927,,,,,,4.35000
+39.5928,,,,,,4.30000
+39.6929,,,,,,4.07000
+39.7931,,,,,,3.90000
+39.8932,,,,,,3.58000
+39.9932,,,,,,3.36000
+40.0933,,,,,,3.26000
+40.1935,,,,,,3.56000
+40.2936,,,,,,3.79000
+40.3938,,,,,,3.65000
+40.4940,,,,,,3.64000
+40.5942,,,,,,3.73000
+40.6942,,,,,,3.62000
+40.7943,,,,,,3.55000
+40.8945,,,,,,3.58000
+40.9946,,,,,,3.70000
+41.0947,,,,,,3.68000
+41.1949,,,,,,3.49000
+41.2951,,,,,,3.49000
+41.3954,,,,,,3.76000
+41.4955,,,,,,3.93000
+41.5957,,,,,,3.72000
+41.6958,,,,,,3.65000
+41.7959,,,,,,3.72000
+41.8961,,,,,,3.87000
+41.9965,,,,,,3.80000
+42.0968,,,,,,3.70000
+42.1971,,,,,,3.70000
+42.2972,,,,,,3.75000
+42.3975,,,,,,3.54000
+42.4979,,,,,,3.38000
+42.5983,,,,,,3.39000
+42.6984,,,,,,3.51000
+42.7988,,,,,,3.51000
+42.8989,,,,,,3.57000
+42.9990,,,,,,3.57000
+43.0991,,,,,,3.49000
+43.1992,,,,,,3.46000
+43.2994,,,,,,3.65000
+43.3995,,,,,,3.65000
+43.4996,,,,,,3.57000
+43.5997,,,,,,3.76000
+43.6998,,,,,,3.79000
+43.7999,,,,,,3.79000
+43.9002,,,,,,3.67000
+44.0006,,,,,,3.82000
+44.1008,,,,,,3.83000
+44.2009,,,,,,3.83000
+44.3013,,,,,,3.59000
+44.4017,,,,,,2.99000
+44.5018,,,,,,2.70000
+44.6020,,,,,,2.63000
+44.7022,,,,,,2.17000
+44.8022,,,,,,1.56000
+44.9024,,,,,,1.56000
+45.0025,,,,,,1.44000
+45.1026,,,,,,1.44000
+45.2027,,,,,,1.47000
+45.3030,,,,,,1.47000
+45.4033,,,,,,1.54000
+45.5034,,,,,,1.54000
+45.6037,,,,,,1.55000
+45.7041,,,,,,1.55000
+45.8043,,,,,,1.71000
+45.9047,,,,,,1.71000
+46.0051,,,,,,1.71000
+46.1054,,,,,,1.71000
+46.2056,,,,,,1.85000
+46.3058,,,,,,1.52000
+46.4062,,,,,,1.55000
+46.5062,,,,,,1.56000
+46.6063,,,,,,1.57000
+46.7067,,,,,,1.58000
+46.8071,,,,,,1.58000
+46.9071,,,,,,1.86000
+47.0075,,,,,,1.86000
+47.1078,,,,,,1.74000
+47.2082,,,,,,1.74000
+47.3084,,,,,,1.73000
+47.4087,,,,,,1.75000
+47.5090,,,,,,1.74000
+47.6092,,,,,,1.74000
+47.7092,,,,,,1.54000
+47.8094,,,,,,1.59000
+47.9096,,,,,,1.59000
+48.0097,,,,,,1.59000
+48.1099,,,,,,1.59000
+48.2101,,,,,,1.59000
+48.3101,,,,,,1.58000
+48.4102,,,,,,1.58000
+48.5103,,,,,,1.58000
+48.6105,,,,,,1.58000
+48.7106,,,,,,1.58000
+48.8108,,,,,,1.57000
+48.9109,,,,,,1.83000
+49.0111,,,,,,1.82000
+49.1112,,,,,,1.82000
+49.2113,,,,,,1.69000
+49.3115,,,,,,1.63000
+49.4116,,,,,,1.77000
+49.5118,,,,,,1.77000
+49.6120,,,,,,1.81000
+49.7121,,,,,,1.95000
+49.8123,,,,,,1.99000
+49.9124,,,,,,1.99000
+50.0125,,,,,,1.99000
+50.1126,,,,,,1.78000
+50.2127,,,,,,1.69000
+50.3129,,,,,,1.69000
+50.4130,,,,,,1.58000
+50.5131,,,,,,1.58000
+50.6135,,,,,,1.58000
+50.7138,,,,,,1.54000
+50.8140,,,,,,1.49000
+50.9141,,,,,,1.48000
+51.0142,,,,,,1.48000
+51.1146,,,,,,1.53000
+51.2147,,,,,,1.54000
+51.3149,,,,,,1.54000
+51.4153,,,,,,1.54000
+51.5156,,,,,,1.54000
+51.6158,,,,,,1.54000
+51.7161,,,,,,1.54000
+51.8164,,,,,,1.54000
+51.9168,,,,,,1.54000
+52.0170,,,,,,1.61000
+52.1173,,,,,,1.64000
+52.2175,,,,,,1.64000
+52.3177,,,,,,1.63000
+52.4178,,,,,,1.54000
+52.5182,,,,,,1.54000
+52.6185,,,,,,1.44000
+52.7186,,,,,,1.42000
+52.8188,,,,,,1.67000
+52.9191,,,,,,1.67000
+53.0195,,,,,,1.61000
+53.1197,,,,,,1.52000
+53.2198,,,,,,1.46000
+53.3199,,,,,,1.46000
+53.4203,,,,,,1.38000
+53.5204,,,,,,1.57000
+53.6208,,,,,,1.14000
+53.7212,,,,,,1.14000
+53.8215,,,,,,0.11000
+53.9217,,,,,,0.00000
+54.0220,,,,,,0.00000
+54.1221,,,,,,0.00000
+54.2225,,,,,,0.00000
+54.3226,,,,,,0.00000
+54.4230,,,,,,0.00000
+54.5233,,,,,,0.00000
+54.6237,,,,,,0.00000
+54.7239,,,,,,0.00000
+54.8242,,,,,,0.00000
+54.9246,,,,,,0.00000
+55.0247,,,,,,0.00000
+55.1249,,,,,,0.00000
+55.2253,,,,,,0.00000
+55.3253,,,,,,0.00000
+55.4255,,,,,,0.00000
+55.5259,,,,,,0.00000
+55.6259,,,,,,0.00000
+55.7261,,,,,,0.00000
+55.8261,,,,,,0.00000
+55.9262,,,,,,0.00000
+56.0263,,,,,,0.00000
+56.1265,,,,,,0.00000
+56.2266,,,,,,0.00000
+56.3267,,,,,,0.00000
+56.4268,,,,,,0.00000
+56.5270,,,,,,0.00000
+56.6272,,,,,,0.00000
+56.7272,,,,,,0.00000
+56.8273,,,,,,0.00000
+56.9275,,,,,,0.00000
+57.0276,,,,,,0.00000
+57.1278,,,,,,0.00000
+57.2280,,,,,,0.00000
+57.3282,,,,,,0.00000
+57.4282,,,,,,0.00000
+57.5284,,,,,,0.00000
+57.6285,,,,,,0.00000
+57.7287,,,,,,0.00000
+57.8288,,,,,,0.00000
+57.9289,,,,,,0.00000
+58.0291,,,,,,0.00000
+58.1294,,,,,,0.00000
+58.2297,,,,,,0.00000
diff --git a/plot.png b/plot.png
deleted file mode 100644
index 1094f27..0000000
--- a/plot.png
+++ /dev/null
Binary files differ
diff --git a/ui/__pycache__/__init__.cpython-312.pyc b/ui/__pycache__/__init__.cpython-312.pyc
new file mode 100644
index 0000000..6ed2349
--- /dev/null
+++ b/ui/__pycache__/__init__.cpython-312.pyc
Binary files differ
diff --git a/ui/__pycache__/__init__.cpython-314.pyc b/ui/__pycache__/__init__.cpython-314.pyc
deleted file mode 100644
index 3af9695..0000000
--- a/ui/__pycache__/__init__.cpython-314.pyc
+++ /dev/null
Binary files differ
diff --git a/ui/__pycache__/add_device_dialog.cpython-314.pyc b/ui/__pycache__/add_device_dialog.cpython-314.pyc
deleted file mode 100644
index e74884b..0000000
--- a/ui/__pycache__/add_device_dialog.cpython-314.pyc
+++ /dev/null
Binary files differ
diff --git a/ui/__pycache__/alarm_panel.cpython-314.pyc b/ui/__pycache__/alarm_panel.cpython-314.pyc
deleted file mode 100644
index a1811f7..0000000
--- a/ui/__pycache__/alarm_panel.cpython-314.pyc
+++ /dev/null
Binary files differ
diff --git a/ui/__pycache__/config_dialog.cpython-314.pyc b/ui/__pycache__/config_dialog.cpython-314.pyc
deleted file mode 100644
index 788f4a0..0000000
--- a/ui/__pycache__/config_dialog.cpython-314.pyc
+++ /dev/null
Binary files differ
diff --git a/ui/__pycache__/control_editor.cpython-312.pyc b/ui/__pycache__/control_editor.cpython-312.pyc
new file mode 100644
index 0000000..f1716c6
--- /dev/null
+++ b/ui/__pycache__/control_editor.cpython-312.pyc
Binary files differ
diff --git a/ui/__pycache__/control_panel.cpython-312.pyc b/ui/__pycache__/control_panel.cpython-312.pyc
new file mode 100644
index 0000000..c54cd25
--- /dev/null
+++ b/ui/__pycache__/control_panel.cpython-312.pyc
Binary files differ
diff --git a/ui/__pycache__/control_panel.cpython-314.pyc b/ui/__pycache__/control_panel.cpython-314.pyc
deleted file mode 100644
index a534649..0000000
--- a/ui/__pycache__/control_panel.cpython-314.pyc
+++ /dev/null
Binary files differ
diff --git a/ui/__pycache__/device_panel.cpython-314.pyc b/ui/__pycache__/device_panel.cpython-314.pyc
deleted file mode 100644
index 07eb750..0000000
--- a/ui/__pycache__/device_panel.cpython-314.pyc
+++ /dev/null
Binary files differ
diff --git a/ui/__pycache__/main_window.cpython-312.pyc b/ui/__pycache__/main_window.cpython-312.pyc
new file mode 100644
index 0000000..b99d7c2
--- /dev/null
+++ b/ui/__pycache__/main_window.cpython-312.pyc
Binary files differ
diff --git a/ui/__pycache__/main_window.cpython-314.pyc b/ui/__pycache__/main_window.cpython-314.pyc
deleted file mode 100644
index d933966..0000000
--- a/ui/__pycache__/main_window.cpython-314.pyc
+++ /dev/null
Binary files differ
diff --git a/ui/__pycache__/profile_manager_ui.cpython-312.pyc b/ui/__pycache__/profile_manager_ui.cpython-312.pyc
new file mode 100644
index 0000000..e66a754
--- /dev/null
+++ b/ui/__pycache__/profile_manager_ui.cpython-312.pyc
Binary files differ
diff --git a/ui/__pycache__/readout_panel.cpython-314.pyc b/ui/__pycache__/readout_panel.cpython-314.pyc
deleted file mode 100644
index 9258b10..0000000
--- a/ui/__pycache__/readout_panel.cpython-314.pyc
+++ /dev/null
Binary files differ
diff --git a/ui/__pycache__/strip_chart.cpython-312.pyc b/ui/__pycache__/strip_chart.cpython-312.pyc
new file mode 100644
index 0000000..ba3ca5d
--- /dev/null
+++ b/ui/__pycache__/strip_chart.cpython-312.pyc
Binary files differ
diff --git a/ui/__pycache__/strip_chart.cpython-314.pyc b/ui/__pycache__/strip_chart.cpython-314.pyc
deleted file mode 100644
index af41096..0000000
--- a/ui/__pycache__/strip_chart.cpython-314.pyc
+++ /dev/null
Binary files differ
diff --git a/ui/alarm_panel.py b/ui/alarm_panel.py
deleted file mode 100644
index 1270aff..0000000
--- a/ui/alarm_panel.py
+++ /dev/null
@@ -1,85 +0,0 @@
-"""
-ui/alarm_panel.py — Alarm event log.
-"""
-
-from datetime import datetime
-from PyQt6.QtWidgets import (
- QWidget, QVBoxLayout, QHBoxLayout, QLabel,
- QScrollArea, QFrame, QPushButton,
-)
-from PyQt6.QtCore import Qt
-
-
-class AlarmRow(QFrame):
- def __init__(self, dev_id: str, ch_id: str, value: float, kind: str):
- super().__init__()
- self.setObjectName("alarmEntry")
- color = "#ef4444" if kind == "high" else "#f59e0b"
- arrow = "▲" if kind == "high" else "▼"
- self.setStyleSheet(f"QFrame#alarmEntry {{ border-left: 3px solid {color}; }}")
-
- ts_str = datetime.now().strftime("%H:%M:%S.%f")[:11]
- layout = QHBoxLayout(self)
- layout.setContentsMargins(8, 3, 8, 3)
-
- msg = QLabel(f"{arrow} {dev_id} / {ch_id} = {value:.4f} [{kind.upper()}]")
- msg.setObjectName("alarmMsg")
- ts = QLabel(ts_str)
- ts.setObjectName("alarmTs")
-
- layout.addWidget(msg, 1)
- layout.addWidget(ts)
-
-
-class AlarmPanel(QWidget):
- def __init__(self):
- super().__init__()
- self._count = 0
- self._build()
-
- def _build(self):
- layout = QVBoxLayout(self)
- layout.setContentsMargins(0, 0, 0, 0)
- layout.setSpacing(0)
-
- hdr_w = QWidget()
- hdr_w.setObjectName("alarmHeaderWidget")
- hdr_lay = QHBoxLayout(hdr_w)
- hdr_lay.setContentsMargins(0, 0, 4, 0)
- hdr = QLabel(" ALARMS")
- hdr.setObjectName("panelHeader")
- hdr.setMinimumHeight(28)
- hdr_lay.addWidget(hdr, 1)
- clr = QPushButton("Clear")
- clr.setObjectName("clearAlarmsBtn")
- clr.clicked.connect(self.clear)
- hdr_lay.addWidget(clr)
- layout.addWidget(hdr_w)
-
- self._scroll = QScrollArea()
- self._scroll.setWidgetResizable(True)
- self._container = QWidget()
- self._inner = QVBoxLayout(self._container)
- self._inner.setContentsMargins(4, 4, 4, 4)
- self._inner.setSpacing(2)
- self._inner.addStretch()
- self._scroll.setWidget(self._container)
- layout.addWidget(self._scroll)
-
- def add_alarm(self, dev_id: str, ch_id: str, value: float, kind: str):
- row = AlarmRow(dev_id, ch_id, value, kind)
- self._inner.insertWidget(0, row)
- self._count += 1
- # Cap at 300 entries
- if self._count > 300:
- item = self._inner.takeAt(self._inner.count() - 2)
- if item and item.widget():
- item.widget().deleteLater()
- self._count -= 1
-
- def clear(self):
- while self._inner.count() > 1:
- item = self._inner.takeAt(0)
- if item and item.widget():
- item.widget().deleteLater()
- self._count = 0
diff --git a/ui/control_editor.py b/ui/control_editor.py
new file mode 100644
index 0000000..93b8110
--- /dev/null
+++ b/ui/control_editor.py
@@ -0,0 +1,319 @@
+"""
+ui/control_editor.py
+
+Dialog for adding or editing a control widget.
+Lets the operator choose:
+ • Control type (On/Off, Motor, PWM, Setpoint, Analog Out)
+ • Title & icon
+ • Target device + channel
+ • Type-specific parameters (max RPM, unit, min/max, step...)
+"""
+
+from __future__ import annotations
+from dataclasses import dataclass, field, asdict
+from typing import Any, Dict, Optional
+
+from PyQt6.QtWidgets import (
+ QDialog, QVBoxLayout, QHBoxLayout, QFormLayout,
+ QLabel, QLineEdit, QComboBox, QDoubleSpinBox,
+ QSpinBox, QPushButton, QGroupBox, QWidget,
+ QStackedWidget, QFrame, QCheckBox,
+)
+from PyQt6.QtCore import Qt
+
+from devices.device_registry import DeviceRegistry
+
+
+# ── Data model ────────────────────────────────────────────────────────────────
+
+CONTROL_TYPES = [
+ "On/Off Switch",
+ "Motor Control",
+ "PWM Output",
+ "Setpoint",
+ "Analog Output",
+]
+
+CONTROL_ICONS = {
+ "On/Off Switch": "⏻",
+ "Motor Control": "⟳",
+ "PWM Output": "⊓",
+ "Setpoint": "◎",
+ "Analog Output": "↗",
+}
+
+
+@dataclass
+class ControlSpec:
+ """Serialisable description of one control widget."""
+ control_type: str = "On/Off Switch"
+ title: str = "Control"
+ icon: str = "⏻"
+ device_id: str = ""
+ channel_id: str = ""
+ # Type-specific params
+ unit: str = ""
+ min_val: float = 0.0
+ max_val: float = 100.0
+ step: float = 1.0
+ max_rpm: int = 3000
+
+ def to_dict(self) -> dict:
+ return asdict(self)
+
+ @staticmethod
+ def from_dict(d: dict) -> "ControlSpec":
+ valid = {k: v for k, v in d.items() if k in ControlSpec.__dataclass_fields__}
+ return ControlSpec(**valid)
+
+
+# ── Per-type parameter panels ─────────────────────────────────────────────────
+
+class OnOffParams(QWidget):
+ def __init__(self): super().__init__() # no extra params needed
+
+ def load(self, spec: ControlSpec): pass
+ def save(self, spec: ControlSpec): pass
+
+
+class MotorParams(QWidget):
+ def __init__(self):
+ super().__init__()
+ lay = QFormLayout(self); lay.setContentsMargins(0, 4, 0, 4)
+ self._rpm = QSpinBox(); self._rpm.setRange(1, 100000); self._rpm.setValue(3000)
+ self._rpm.setSuffix(" RPM")
+ lay.addRow("Max RPM:", self._rpm)
+
+ def load(self, spec: ControlSpec): self._rpm.setValue(spec.max_rpm)
+ def save(self, spec: ControlSpec): spec.max_rpm = self._rpm.value()
+
+
+class PwmParams(QWidget):
+ def __init__(self): super().__init__() # duty handled by slider, no extra params
+
+ def load(self, spec: ControlSpec): pass
+ def save(self, spec: ControlSpec): pass
+
+
+class SetpointParams(QWidget):
+ def __init__(self):
+ super().__init__()
+ lay = QFormLayout(self); lay.setContentsMargins(0, 4, 0, 4)
+ self._unit = QLineEdit(); self._unit.setPlaceholderText("e.g. °C, bar, rpm")
+ self._min = QDoubleSpinBox(); self._min.setRange(-1e9, 1e9); self._min.setValue(0.0)
+ self._max = QDoubleSpinBox(); self._max.setRange(-1e9, 1e9); self._max.setValue(100.0)
+ self._step = QDoubleSpinBox(); self._step.setRange(0.001, 1e6); self._step.setValue(1.0)
+ lay.addRow("Unit:", self._unit)
+ lay.addRow("Min:", self._min)
+ lay.addRow("Max:", self._max)
+ lay.addRow("Step:", self._step)
+
+ def load(self, spec: ControlSpec):
+ self._unit.setText(spec.unit)
+ self._min.setValue(spec.min_val)
+ self._max.setValue(spec.max_val)
+ self._step.setValue(spec.step)
+
+ def save(self, spec: ControlSpec):
+ spec.unit = self._unit.text().strip()
+ spec.min_val = self._min.value()
+ spec.max_val = self._max.value()
+ spec.step = self._step.value()
+
+
+class AnalogOutParams(QWidget):
+ def __init__(self):
+ super().__init__()
+ lay = QFormLayout(self); lay.setContentsMargins(0, 4, 0, 4)
+ self._unit = QLineEdit("V"); self._unit.setPlaceholderText("V, mA, …")
+ self._min = QDoubleSpinBox(); self._min.setRange(-1e9, 1e9); self._min.setValue(0.0)
+ self._max = QDoubleSpinBox(); self._max.setRange(-1e9, 1e9); self._max.setValue(10.0)
+ lay.addRow("Unit:", self._unit)
+ lay.addRow("Min:", self._min)
+ lay.addRow("Max:", self._max)
+
+ def load(self, spec: ControlSpec):
+ self._unit.setText(spec.unit)
+ self._min.setValue(spec.min_val)
+ self._max.setValue(spec.max_val)
+
+ def save(self, spec: ControlSpec):
+ spec.unit = self._unit.text().strip()
+ spec.min_val = self._min.value()
+ spec.max_val = self._max.value()
+
+
+_PARAM_PANELS = {
+ "On/Off Switch": OnOffParams,
+ "Motor Control": MotorParams,
+ "PWM Output": PwmParams,
+ "Setpoint": SetpointParams,
+ "Analog Output": AnalogOutParams,
+}
+
+
+# ── Editor dialog ─────────────────────────────────────────────────────────────
+
+class ControlEditorDialog(QDialog):
+ """Add or edit a control widget."""
+
+ def __init__(self, registry: DeviceRegistry,
+ spec: Optional[ControlSpec] = None,
+ parent=None):
+ super().__init__(parent)
+ self.registry = registry
+ self.spec = spec or ControlSpec()
+ self.result_spec: Optional[ControlSpec] = None
+
+ self.setWindowTitle("Edit Control" if spec else "Add Control")
+ self.setMinimumSize(440, 460)
+ self.resize(460, 500)
+ self._build()
+ self._load_spec()
+
+ def _build(self):
+ root = QVBoxLayout(self); root.setSpacing(10)
+
+ # ── Identity ────────────────────────────────────────────────────
+ id_grp = QGroupBox("Identity")
+ id_form = QFormLayout(id_grp); id_form.setContentsMargins(10, 16, 10, 10)
+
+ self._type_cb = QComboBox()
+ self._type_cb.addItems(CONTROL_TYPES)
+ self._type_cb.currentTextChanged.connect(self._on_type_changed)
+ id_form.addRow("Control Type:", self._type_cb)
+
+ self._title_edit = QLineEdit()
+ self._title_edit.setPlaceholderText("e.g. Heater, Pump, Mixer")
+ id_form.addRow("Label:", self._title_edit)
+
+ root.addWidget(id_grp)
+
+ # ── Device & channel ────────────────────────────────────────────
+ hw_grp = QGroupBox("Hardware Assignment")
+ hw_form = QFormLayout(hw_grp); hw_form.setContentsMargins(10, 16, 10, 10)
+
+ self._dev_cb = QComboBox()
+ self._dev_cb.addItem("— none —", userData="")
+ for dev in self.registry.all_instances():
+ label = f"{dev.info.device_id} ({dev.info.name})"
+ self._dev_cb.addItem(label, userData=dev.info.device_id)
+ self._dev_cb.currentIndexChanged.connect(self._on_dev_changed)
+ hw_form.addRow("Device:", self._dev_cb)
+
+ self._ch_cb = QComboBox()
+ self._ch_cb.setEditable(True) # allow typing custom pin like "D7"
+ self._ch_cb.setInsertPolicy(QComboBox.InsertPolicy.NoInsert)
+ hw_form.addRow("Channel / Pin:", self._ch_cb)
+
+ self._ch_hint = QLabel("")
+ self._ch_hint.setObjectName("traceSource")
+ self._ch_hint.setWordWrap(True)
+ hw_form.addRow(self._ch_hint)
+
+ root.addWidget(hw_grp)
+
+ # ── Type-specific params ────────────────────────────────────────
+ self._params_grp = QGroupBox("Parameters")
+ params_lay = QVBoxLayout(self._params_grp)
+ params_lay.setContentsMargins(10, 16, 10, 10)
+
+ self._stack = QStackedWidget()
+ self._param_panels: Dict[str, QWidget] = {}
+ for name, cls in _PARAM_PANELS.items():
+ panel = cls()
+ self._param_panels[name] = panel
+ self._stack.addWidget(panel)
+ params_lay.addWidget(self._stack)
+ root.addWidget(self._params_grp)
+
+ # ── Buttons ────────────────────────────────────────────────────
+ div = QFrame(); div.setFrameShape(QFrame.Shape.HLine)
+ div.setObjectName("devWindowDivider"); root.addWidget(div)
+
+ btn_row = QHBoxLayout(); btn_row.addStretch()
+ cancel = QPushButton("Cancel"); cancel.clicked.connect(self.reject)
+ ok = QPushButton("Save Control")
+ ok.setObjectName("applyButton"); ok.setDefault(True)
+ ok.clicked.connect(self._on_ok)
+ btn_row.addWidget(cancel); btn_row.addWidget(ok)
+ root.addLayout(btn_row)
+
+ def _load_spec(self):
+ """Populate fields from self.spec."""
+ # Type
+ idx = CONTROL_TYPES.index(self.spec.control_type) \
+ if self.spec.control_type in CONTROL_TYPES else 0
+ self._type_cb.setCurrentIndex(idx)
+ self._title_edit.setText(self.spec.title)
+
+ # Device
+ for i in range(self._dev_cb.count()):
+ if self._dev_cb.itemData(i) == self.spec.device_id:
+ self._dev_cb.setCurrentIndex(i); break
+
+ # Channel (populated after device selection)
+ self._on_dev_changed()
+ self._ch_cb.setCurrentText(self.spec.channel_id)
+
+ # Params
+ for name, panel in self._param_panels.items():
+ panel.load(self.spec)
+
+ def _on_type_changed(self, type_name: str):
+ idx = list(_PARAM_PANELS.keys()).index(type_name)
+ self._stack.setCurrentIndex(idx)
+ # Auto-set title if still default
+ if not self._title_edit.text() or \
+ self._title_edit.text() in CONTROL_TYPES:
+ self._title_edit.setText(type_name)
+
+ def _on_dev_changed(self):
+ self._ch_cb.clear()
+ dev_id = self._dev_cb.currentData()
+ if not dev_id:
+ self._ch_hint.setText("No device selected — type a channel manually")
+ return
+ dev = self.registry.get_instance(dev_id)
+ if not dev:
+ return
+ # Add actual channels
+ for ch in dev.info.channels:
+ self._ch_cb.addItem(f"{ch.channel_id} ({ch.name})",
+ userData=ch.channel_id)
+ # For Arduino backends also suggest digital pins for output
+ if hasattr(dev, "backend") and dev.backend == "arduino":
+ self._ch_cb.insertSeparator(self._ch_cb.count())
+ for pin in ["D5", "D6", "D7", "D8", "D9", "D10", "D11", "D13"]:
+ self._ch_cb.addItem(f"{pin} (digital out)", userData=pin)
+ self._ch_hint.setText(
+ "Analog pins (A0…) for reading.\n"
+ "Digital pins (D5…) for on/off output.\n"
+ "D9, D10, D11 support PWM."
+ )
+ else:
+ self._ch_hint.setText("")
+
+ def _on_ok(self):
+ ctype = self._type_cb.currentText()
+ title = self._title_edit.text().strip() or ctype
+
+ # Resolve channel_id — prefer userData if it's a real combo item
+ ch_text = self._ch_cb.currentText().strip()
+ ch_data = self._ch_cb.currentData()
+ channel_id = ch_data if ch_data else ch_text.split()[0] # strip "(name)" part
+
+ spec = ControlSpec(
+ control_type=ctype,
+ title=title,
+ icon=CONTROL_ICONS.get(ctype, "⚙"),
+ device_id=self._dev_cb.currentData() or "",
+ channel_id=channel_id,
+ )
+ # Save type-specific params
+ panel = self._param_panels.get(ctype)
+ if panel:
+ panel.save(spec)
+
+ self.result_spec = spec
+ self.accept()
diff --git a/ui/control_panel.py b/ui/control_panel.py
index 98f0637..d0f8637 100644
--- a/ui/control_panel.py
+++ b/ui/control_panel.py
@@ -83,11 +83,25 @@ class ControlWidget(QFrame):
outer.addWidget(self._body)
def _write(self, value: float):
- """Write value to the linked device channel (if any)."""
+ """
+ Write value to the linked device channel.
+
+ Routing:
+ DigitalIODevice → write_channel() → ArduinoLayer.digital_write() → W:Dxx:val
+ AnalogInputDevice (arduino backend) → write_channel() → ArduinoLayer.digital_write()
+ Any device with no write support → logs a warning, emits signal only
+ """
+ written = False
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)
+ ok = dev.write_channel(self.channel_id, value)
+ if ok:
+ written = True
+ else:
+ print(f"[Control] write_channel({self.channel_id}, {value}) "
+ f"returned False on {self.device_id} — "
+ f"check device type and channel ID")
self.value_changed.emit(self.channel_id, value)
@@ -98,8 +112,8 @@ class ControlWidget(QFrame):
class OnOffSwitch(ControlWidget):
"""Large latching power switch with green/red indicator."""
- def __init__(self, title: str = "Power", **kw):
- super().__init__(title, icon="⏻", **kw)
+ def __init__(self, title: str = "Power", icon: str = "⏻", **kw):
+ super().__init__(title, icon=icon, **kw)
self._state = False
self._build_body()
@@ -151,8 +165,8 @@ class OnOffSwitch(ControlWidget):
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)
+ def __init__(self, title: str = "Motor", icon: str = "⟳", max_rpm: int = 3000, **kw):
+ super().__init__(title, icon=icon, **kw)
self.max_rpm = max_rpm
self._running = False
self._fwd = True
@@ -238,10 +252,10 @@ class MotorControl(ControlWidget):
class SetpointControl(ControlWidget):
"""Numeric setpoint with ± step buttons and live process-value readback."""
- def __init__(self, title: str = "Setpoint", unit: str = "",
+ def __init__(self, title: str = "Setpoint", icon: str = "◎", unit: str = "",
min_val: float = 0.0, max_val: float = 100.0,
step: float = 1.0, **kw):
- super().__init__(title, icon="◎", **kw)
+ super().__init__(title, icon=icon, **kw)
self.unit = unit
self.min_val = min_val
self.max_val = max_val
@@ -330,8 +344,8 @@ class SetpointControl(ControlWidget):
class PwmControl(ControlWidget):
"""PWM duty cycle slider + frequency setting."""
- def __init__(self, title: str = "PWM Output", **kw):
- super().__init__(title, icon="⊓", **kw)
+ def __init__(self, title: str = "PWM Output", icon: str = "⊓", **kw):
+ super().__init__(title, icon=icon, **kw)
self._build_body()
def _build_body(self):
@@ -399,9 +413,9 @@ class PwmControl(ControlWidget):
class AnalogOutputControl(ControlWidget):
"""Generic voltage/current analog output with spinbox + send button."""
- def __init__(self, title: str = "Analog Out", unit: str = "V",
+ def __init__(self, title: str = "Analog Out", icon: str = "↗", unit: str = "V",
min_val: float = 0.0, max_val: float = 10.0, **kw):
- super().__init__(title, icon="↗", **kw)
+ super().__init__(title, icon=icon, **kw)
self.unit = unit
self.min_val = min_val
self.max_val = max_val
@@ -451,24 +465,39 @@ class AnalogOutputControl(ControlWidget):
# ══════════════════════════════════════════════════════════════════════════════
class ControlPanel(QWidget):
- """Left panel — output control widgets only. No device management here."""
+ """
+ Left panel — output control widgets.
+ Header has Add button. Each widget has Edit and Remove buttons overlaid.
+ """
+
+ controls_changed = pyqtSignal() # emitted whenever widgets are added/edited/removed
def __init__(self, registry: DeviceRegistry):
super().__init__()
self.registry = registry
- self._widgets: list[ControlWidget] = []
+ self._widgets: list[ControlWidget] = []
+ self._specs: list = [] # parallel list of ControlSpec
self._build()
- self._add_demo_widgets()
+
+ # ── Layout ───────────────────────────────────────────────────────────────
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)
+ # Header with Add button
+ hdr_widget = QWidget(); hdr_widget.setObjectName("controlPanelHeader")
+ hdr_widget.setFixedHeight(30)
+ hdr_lay = QHBoxLayout(hdr_widget)
+ hdr_lay.setContentsMargins(8, 0, 6, 0)
+ hdr_lbl = QLabel("CONTROLS"); hdr_lbl.setObjectName("panelHeader")
+ hdr_lay.addWidget(hdr_lbl, 1)
+ add_btn = QPushButton("+"); add_btn.setObjectName("devicesSmallBtn")
+ add_btn.setFixedSize(24, 22); add_btn.setToolTip("Add control widget")
+ add_btn.clicked.connect(self._on_add)
+ hdr_lay.addWidget(add_btn)
+ layout.addWidget(hdr_widget)
scroll = QScrollArea()
scroll.setWidgetResizable(True)
@@ -477,83 +506,159 @@ class ControlPanel(QWidget):
self._container = QWidget()
self._inner = QVBoxLayout(self._container)
- self._inner.setContentsMargins(8, 8, 8, 8)
- self._inner.setSpacing(10)
+ self._inner.setContentsMargins(6, 6, 6, 6)
+ self._inner.setSpacing(8)
self._inner.addStretch()
scroll.setWidget(self._container)
layout.addWidget(scroll)
- def add_widget(self, widget: ControlWidget):
- """Add a control widget to the panel."""
+ # ── Widget management ─────────────────────────────────────────────────────
+
+ def _make_wrapper(self, widget: ControlWidget, spec) -> QFrame:
+ """Wrap a ControlWidget with Edit / Remove buttons in the corner."""
+ wrapper = QFrame(); wrapper.setObjectName("controlWidgetWrapper")
+ wl = QVBoxLayout(wrapper); wl.setContentsMargins(0, 0, 0, 0); wl.setSpacing(0)
+ wl.addWidget(widget)
+
+ # Button row below each widget
+ btn_row = QHBoxLayout(); btn_row.setContentsMargins(2, 1, 2, 1)
+ btn_row.addStretch()
+
+ edit_btn = QPushButton("✎ Edit"); edit_btn.setObjectName("configButton")
+ edit_btn.setFixedHeight(20)
+ rm_btn = QPushButton("✕"); rm_btn.setObjectName("traceRemoveBtn")
+ rm_btn.setFixedSize(20, 20)
+
+ edit_btn.clicked.connect(lambda: self._on_edit(widget, spec, wrapper))
+ rm_btn.clicked.connect( lambda: self._on_remove(widget, spec, wrapper))
+
+ btn_row.addWidget(edit_btn)
+ btn_row.addWidget(rm_btn)
+ wl.addLayout(btn_row)
+ return wrapper
+
+ def _add_widget_from_spec(self, spec):
+ """Instantiate a ControlWidget from a ControlSpec and add to panel."""
+ from ui.control_editor import ControlSpec as CS
+ widget = _build_widget_from_spec(spec, self.registry)
+ if widget is None:
+ return
+ wrapper = self._make_wrapper(widget, spec)
self._widgets.append(widget)
- self._inner.insertWidget(self._inner.count() - 1, widget)
+ self._specs.append(spec)
+ self._inner.insertWidget(self._inner.count() - 1, wrapper)
+ self.controls_changed.emit()
+
+ def add_widget(self, widget: ControlWidget, spec=None):
+ """Legacy API — add a pre-built widget directly."""
+ from ui.control_editor import ControlSpec
+ if spec is None:
+ spec = ControlSpec(
+ title=widget.title, icon=widget.icon,
+ device_id=widget.device_id, channel_id=widget.channel_id,
+ )
+ wrapper = self._make_wrapper(widget, spec)
+ self._widgets.append(widget)
+ self._specs.append(spec)
+ self._inner.insertWidget(self._inner.count() - 1, wrapper)
def clear_widgets(self):
- """Remove all control widgets."""
- for w in self._widgets:
- self._inner.removeWidget(w)
- w.deleteLater()
+ while self._inner.count() > 1:
+ item = self._inner.takeAt(0)
+ if item and item.widget():
+ item.widget().deleteLater()
self._widgets.clear()
+ self._specs.clear()
+
+ def get_specs(self) -> list:
+ """Return list of ControlSpec for all current widgets (for profile save)."""
+ return list(self._specs)
+
+ def load_specs(self, specs: list):
+ """Load a list of ControlSpec objects (from profile restore)."""
+ self.clear_widgets()
+ for spec in specs:
+ self._add_widget_from_spec(spec)
+
+ # ── Slots ─────────────────────────────────────────────────────────────────
+
+ def _on_add(self):
+ from ui.control_editor import ControlEditorDialog
+ dlg = ControlEditorDialog(self.registry, parent=self)
+ if dlg.exec() and dlg.result_spec:
+ self._add_widget_from_spec(dlg.result_spec)
+
+ def _on_edit(self, widget: ControlWidget, spec, wrapper: QFrame):
+ from ui.control_editor import ControlEditorDialog
+ dlg = ControlEditorDialog(self.registry, spec=spec, parent=self)
+ if not (dlg.exec() and dlg.result_spec):
+ return
+ new_spec = dlg.result_spec
+ idx = self._specs.index(spec)
+
+ # Remove old wrapper
+ self._inner.removeWidget(wrapper); wrapper.deleteLater()
+ self._widgets.pop(idx); self._specs.pop(idx)
+
+ # Insert new one at same position
+ new_widget = _build_widget_from_spec(new_spec, self.registry)
+ if new_widget is None:
+ return
+ new_wrapper = self._make_wrapper(new_widget, new_spec)
+ self._widgets.insert(idx, new_widget)
+ self._specs.insert(idx, new_spec)
+ self._inner.insertWidget(idx, new_wrapper)
+ self.controls_changed.emit()
+
+ def _on_remove(self, widget: ControlWidget, spec, wrapper: QFrame):
+ idx = self._specs.index(spec)
+ self._inner.removeWidget(wrapper); wrapper.deleteLater()
+ self._widgets.pop(idx); self._specs.pop(idx)
+ self.controls_changed.emit()
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)
+ """Default demo configuration."""
+ from ui.control_editor import ControlSpec
+ demos = [
+ ControlSpec("On/Off Switch", "Pump Power", "⏻", "dio_0", "do0"),
+ ControlSpec("On/Off Switch", "Heater", "⏻", "ard_0", "D7"),
+ ControlSpec("Motor Control", "Drive Motor", "⟳", "ard_0", "A0", max_rpm=3000),
+ ControlSpec("Setpoint", "Temp Setpoint","◎", "", "", unit="°C", min_val=0, max_val=300, step=0.5),
+ ControlSpec("Setpoint", "Flow Rate", "◎", "", "", unit="mL/min",min_val=0, max_val=500, step=5.0),
+ ControlSpec("PWM Output", "PWM Ch 1", "⊓", "ard_0", "D9"),
+ ControlSpec("Analog Output", "Analog Out", "↗", "", "", unit="V", min_val=0, max_val=10),
+ ]
+ for spec in demos:
+ self._add_widget_from_spec(spec)
+
+
+# ── Factory — build a ControlWidget from a ControlSpec ─────────────────────────
+
+def _build_widget_from_spec(spec, registry: DeviceRegistry) -> Optional[ControlWidget]:
+ """Instantiate the right ControlWidget subclass from a ControlSpec."""
+ # Base kwargs — icon is NOT included here; it's passed explicitly below
+ # so subclasses never get a double-value collision.
+ base_kw = dict(
+ device_id=spec.device_id,
+ channel_id=spec.channel_id,
+ registry=registry,
+ )
+ t = spec.control_type
+ ttl = spec.title
+ ico = spec.icon
+ if t == "On/Off Switch":
+ return OnOffSwitch(title=ttl, icon=ico, **base_kw)
+ elif t == "Motor Control":
+ return MotorControl(title=ttl, icon=ico, max_rpm=spec.max_rpm, **base_kw)
+ elif t == "PWM Output":
+ return PwmControl(title=ttl, icon=ico, **base_kw)
+ elif t == "Setpoint":
+ return SetpointControl(title=ttl, icon=ico, unit=spec.unit,
+ min_val=spec.min_val, max_val=spec.max_val,
+ step=spec.step, **base_kw)
+ elif t == "Analog Output":
+ return AnalogOutputControl(title=ttl, icon=ico, unit=spec.unit,
+ min_val=spec.min_val, max_val=spec.max_val,
+ **base_kw)
+ return None
diff --git a/ui/main_window.py b/ui/main_window.py
index e69ab1a..a82a389 100644
--- a/ui/main_window.py
+++ b/ui/main_window.py
@@ -1,21 +1,15 @@
"""
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.
+Toolbar (left→right):
+ [▶ RUN] [⬤ LOG] | [⊞ Devices] | [⚗ Signals] | [📐 Plot] | [⚙ Settings]
+ spacer | timer | [📁 File]
"""
from PyQt6.QtWidgets import (
QMainWindow, QWidget, QHBoxLayout, QVBoxLayout,
QSplitter, QStatusBar, QLabel, QPushButton,
- QToolBar, QSizePolicy, QApplication,
+ QToolBar, QSizePolicy, QApplication, QFrame,
)
from PyQt6.QtCore import Qt, QTimer, pyqtSlot
import os
@@ -26,13 +20,15 @@ from devices.serial_device import SerialDevice
from devices.device_registry import DeviceRegistry
from core.acquisition import AcquisitionEngine
from core.signal_processor import SignalProcessor
+from core.profile import Profile, ProfileManager
-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
+from ui.control_panel import ControlPanel
+from ui.strip_chart import StripChartWidget
+from ui.profile_manager_ui import ProfileButton
+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")
@@ -50,11 +46,11 @@ class MainWindow(QMainWindow):
self.processor = SignalProcessor()
self._settings = dict(SettingsWindow._defaults)
- self._elapsed = 0
- self._win_devices = None
- self._win_signals = None
- self._win_plot = None
- self._win_settings = None
+ self._elapsed = 0
+ self._win_devices = None
+ self._win_signals = None
+ self._win_plot = None
+ self._win_settings = None
self._init_demo_devices()
self._build_ui()
@@ -76,7 +72,6 @@ class MainWindow(QMainWindow):
# ── UI ────────────────────────────────────────────────────────────────
def _build_ui(self):
- # ── Toolbar ──────────────────────────────────────────────────────
tb = QToolBar(); tb.setObjectName("mainToolbar"); tb.setMovable(False)
self.addToolBar(tb)
@@ -84,7 +79,6 @@ class MainWindow(QMainWindow):
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)
@@ -96,46 +90,50 @@ class MainWindow(QMainWindow):
tb.addWidget(_sep())
- # DEVICES
dev_btn = QPushButton("⊞ Devices")
- dev_btn.setObjectName("toolbarSectionBtn")
- dev_btn.setCheckable(True)
+ 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.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())
- # PLOT
plot_btn = QPushButton("📐 Plot")
- plot_btn.setObjectName("toolbarSectionBtn")
- plot_btn.setCheckable(True)
+ 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.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)
+ # Spacer + clock
+ spacer = QWidget()
+ spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
tb.addWidget(spacer)
self._time_lbl = QLabel("00:00:00"); self._time_lbl.setObjectName("timeLabel")
tb.addWidget(self._time_lbl)
+ tb.addWidget(_sep())
+
+ # ── 📁 File button (profiles) ─────────────────────────────────────
+ self._file_btn = ProfileButton(
+ on_new=self._profile_new,
+ get_profile=self._profile_capture,
+ apply_profile=self._profile_apply,
+ )
+ tb.addWidget(self._file_btn)
+
# ── Central ───────────────────────────────────────────────────────
central = QWidget(); self.setCentralWidget(central)
root = QHBoxLayout(central); root.setContentsMargins(0,0,0,0); root.setSpacing(0)
@@ -143,16 +141,16 @@ class MainWindow(QMainWindow):
hsplit = QSplitter(Qt.Orientation.Horizontal); hsplit.setHandleWidth(3)
self._ctrl = ControlPanel(self.registry)
- self._ctrl.setMinimumWidth(200); self._ctrl.setMaximumWidth(320)
+ self._ctrl.setMinimumWidth(200); self._ctrl.setMaximumWidth(340)
+ self._ctrl._add_demo_widgets()
hsplit.addWidget(self._ctrl)
self._chart = StripChartWidget(self.engine, self.registry, self.processor)
hsplit.addWidget(self._chart)
- hsplit.setSizes([240, 1000])
+ hsplit.setSizes([260, 1000])
root.addWidget(hsplit)
- # ── Status bar ─────────────────────────────────────────────────
sb = QStatusBar(); self.setStatusBar(sb)
self._status = QLabel("Ready"); sb.addWidget(self._status)
self._log_lbl = QLabel(""); sb.addPermanentWidget(self._log_lbl)
@@ -229,11 +227,51 @@ class MainWindow(QMainWindow):
if not win.isVisible():
if position == "right":
win.move(geo.right() + 8, geo.top() + 40)
- elif position == "below":
+ else:
win.move(geo.left(), geo.bottom() + 8)
win.show(); win.raise_(); win.activateWindow()
- # ── Device/signal events ──────────────────────────────────────────────
+ # ── Profile callbacks ─────────────────────────────────────────────────
+
+ def _profile_new(self):
+ """Reset to a blank slate."""
+ self._ctrl.clear_widgets()
+ # Clear derived channels
+ for dc in self.processor.get_derived():
+ self.processor.remove_derived(dc.channel_id)
+ self._chart.refresh()
+ self._status.setText("New profile — blank slate.")
+
+ def _profile_capture(self, name: str = "Profile") -> Profile:
+ """Serialise current state into a Profile object."""
+ return ProfileManager.capture(
+ registry=self.registry,
+ processor=self.processor,
+ plot_cfg=self._chart._cfg,
+ control_specs=self._ctrl.get_specs(),
+ settings=self._settings,
+ profile_name=name,
+ )
+
+ def _profile_apply(self, profile: Profile):
+ """Restore state from a Profile object."""
+ plot_cfg = ProfileManager.apply(
+ profile=profile,
+ registry=self.registry,
+ processor=self.processor,
+ control_panel=self._ctrl,
+ settings_ref=self._settings,
+ )
+ if plot_cfg:
+ self._chart.apply_layout(plot_cfg)
+ else:
+ self._chart.refresh()
+ # Refresh open windows
+ if self._win_plot:
+ self._win_plot.refresh_channels()
+ self._status.setText(f"Profile loaded: {profile.name}")
+
+ # ── Device / signal events ────────────────────────────────────────────
def _on_device_added(self):
self._chart.refresh()
@@ -245,10 +283,8 @@ class MainWindow(QMainWindow):
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()
+ if self._win_plot: self._win_plot.refresh_channels()
self._status.setText(f"Device '{dev_id}' reconfigured.")
def _on_derived_changed(self):
@@ -271,22 +307,20 @@ class MainWindow(QMainWindow):
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"), ""))
+ os.path.join(self._settings.get("log_dir", "logs"), ""))
self._log_btn.setText("⏹ LOGGING")
self._status.setText(f"Logging → {p}")
else:
self.engine.stop_logging(); self._log_btn.setText("⬤ LOG")
- # ── Settings ──────────────────────────────────────────────────────────
+ # ── Theme / 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):
@@ -299,9 +333,7 @@ class MainWindow(QMainWindow):
self._time_lbl.setText(f"{h:02d}:{m:02d}:{s:02d}")
def closeEvent(self, event):
- for w in (self._win_devices,self._win_signals,self._win_plot,self._win_settings):
+ 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/profile_manager_ui.py b/ui/profile_manager_ui.py
new file mode 100644
index 0000000..8966f8f
--- /dev/null
+++ b/ui/profile_manager_ui.py
@@ -0,0 +1,152 @@
+"""
+ui/profile_manager_ui.py
+
+Profile menu button (📁) in the top-right toolbar.
+Handles New / Load / Save / Save As via file dialogs.
+Thin UI layer — all serialisation is in core/profile.py.
+"""
+
+import os
+from PyQt6.QtWidgets import (
+ QMenu, QPushButton, QFileDialog, QInputDialog,
+ QMessageBox, QApplication,
+)
+from PyQt6.QtCore import QPoint
+from PyQt6.QtGui import QAction
+
+from core.profile import Profile, ProfileManager
+
+
+PROFILE_EXT = ".labdaq"
+PROFILE_FILTER = f"LabDAQ Profile (*{PROFILE_EXT});;All files (*)"
+
+
+class ProfileButton(QPushButton):
+ """
+ A '📁 File' button that shows New / Load / Save / Save As.
+ Placed in the toolbar top-right.
+
+ Caller provides callbacks:
+ on_new() — reset to blank state
+ get_profile() — return a Profile representing current state
+ apply_profile(p) — restore state from a Profile
+ """
+
+ def __init__(self, on_new, get_profile, apply_profile, parent=None):
+ super().__init__("📁 File", parent)
+ self.setObjectName("toolbarSectionBtn")
+ self._on_new = on_new
+ self._get_profile = get_profile
+ self._apply_profile = apply_profile
+ self._current_path: str = ""
+ self.clicked.connect(self._show_menu)
+
+ # ── Menu ──────────────────────────────────────────────────────────────────
+
+ def _show_menu(self):
+ menu = QMenu(self)
+ menu.setObjectName("profileMenu")
+
+ a_new = menu.addAction("📄 New Profile")
+ menu.addSeparator()
+ a_open = menu.addAction("📂 Load Profile…")
+ menu.addSeparator()
+ a_save = menu.addAction("💾 Save")
+ a_save_as = menu.addAction("💾 Save As…")
+
+ a_save.setEnabled(bool(self._current_path))
+
+ a_new.triggered.connect(self._do_new)
+ a_open.triggered.connect(self._do_load)
+ a_save.triggered.connect(self._do_save)
+ a_save_as.triggered.connect(self._do_save_as)
+
+ # Show below the button
+ pos = self.mapToGlobal(QPoint(0, self.height()))
+ menu.exec(pos)
+
+ # ── Actions ───────────────────────────────────────────────────────────────
+
+ def _do_new(self):
+ reply = QMessageBox.question(
+ self, "New Profile",
+ "Discard current configuration and start a blank profile?",
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
+ )
+ if reply == QMessageBox.StandardButton.Yes:
+ self._current_path = ""
+ self._on_new()
+ self._update_title()
+
+ def _do_load(self):
+ path, _ = QFileDialog.getOpenFileName(
+ self, "Load Profile", self._default_dir(),
+ PROFILE_FILTER,
+ )
+ if not path:
+ return
+ try:
+ profile = Profile.load(path)
+ self._apply_profile(profile)
+ self._current_path = path
+ self._update_title(profile.name)
+ except Exception as e:
+ QMessageBox.critical(self, "Load Failed", str(e))
+
+ def _do_save(self):
+ if not self._current_path:
+ self._do_save_as()
+ return
+ self._save_to(self._current_path)
+
+ def _do_save_as(self):
+ # Ask for profile name
+ name, ok = QInputDialog.getText(
+ self, "Profile Name", "Profile name:",
+ text=os.path.splitext(os.path.basename(self._current_path))[0]
+ if self._current_path else "My Profile",
+ )
+ if not ok or not name.strip():
+ return
+ name = name.strip()
+
+ path, _ = QFileDialog.getSaveFileName(
+ self, "Save Profile As",
+ os.path.join(self._default_dir(), name + PROFILE_EXT),
+ PROFILE_FILTER,
+ )
+ if not path:
+ return
+ if not path.endswith(PROFILE_EXT):
+ path += PROFILE_EXT
+ self._save_to(path, name)
+
+ def _save_to(self, path: str, name: str = ""):
+ try:
+ profile = self._get_profile(name or os.path.splitext(
+ os.path.basename(path))[0])
+ profile.save(path)
+ self._current_path = path
+ self._update_title(profile.name)
+ except Exception as e:
+ QMessageBox.critical(self, "Save Failed", str(e))
+
+ # ── Helpers ───────────────────────────────────────────────────────────────
+
+ def _default_dir(self) -> str:
+ d = os.path.join(os.path.expanduser("~"), "labdaq_profiles")
+ os.makedirs(d, exist_ok=True)
+ return d
+
+ def _update_title(self, name: str = ""):
+ app = QApplication.instance()
+ if app and hasattr(app, "topLevelWidgets"):
+ for w in app.topLevelWidgets():
+ if hasattr(w, "setWindowTitle") and "LabDAQ" in (w.windowTitle() or ""):
+ title = "LabDAQ"
+ if name:
+ title += f" — {name}"
+ if self._current_path:
+ title += f" [{os.path.basename(self._current_path)}]"
+ w.setWindowTitle(title)
+ break
diff --git a/ui/readout_panel.py b/ui/readout_panel.py
deleted file mode 100644
index ef04e18..0000000
--- a/ui/readout_panel.py
+++ /dev/null
@@ -1,122 +0,0 @@
-"""
-ui/readout_panel.py — Right-side numeric readout panel.
-"""
-
-from PyQt6.QtWidgets import (
- QWidget, QVBoxLayout, QHBoxLayout, QLabel,
- QScrollArea, QFrame, QProgressBar,
-)
-from PyQt6.QtCore import Qt, pyqtSlot
-from devices.device_registry import DeviceRegistry
-
-
-class ChannelReadout(QFrame):
- def __init__(self, ch_config):
- super().__init__()
- self.ch = ch_config
- self.setObjectName("channelReadout")
- self._alarm = False
- self._build()
-
- def _build(self):
- layout = QVBoxLayout(self)
- layout.setContentsMargins(8, 6, 8, 6)
- layout.setSpacing(2)
-
- top = QHBoxLayout()
- self._name = QLabel(self.ch.name)
- self._name.setObjectName("readoutName")
- top.addWidget(self._name, 1)
-
- self._val = QLabel("— — —")
- self._val.setObjectName("readoutValue")
- self._val.setStyleSheet(f"color:{self.ch.color};")
- top.addWidget(self._val)
-
- unit = QLabel(f" {self.ch.unit}")
- unit.setObjectName("readoutUnit")
- top.addWidget(unit)
- layout.addLayout(top)
-
- self._bar = QProgressBar()
- self._bar.setObjectName("readoutBar")
- self._bar.setRange(0, 1000)
- self._bar.setValue(500)
- self._bar.setTextVisible(False)
- self._bar.setMaximumHeight(3)
- self._bar.setStyleSheet(
- f"QProgressBar::chunk {{ background:{self.ch.color}; border-radius:1px; }}"
- )
- layout.addWidget(self._bar)
-
- def update_value(self, v: float):
- self._val.setText(f"{v:>10.4f}".strip())
- rng = self.ch.max_value - self.ch.min_value
- norm = int(((v - self.ch.min_value) / rng) * 1000) if rng else 500
- self._bar.setValue(max(0, min(1000, norm)))
-
- alarm = (
- (self.ch.alarm_low is not None and v < self.ch.alarm_low) or
- (self.ch.alarm_high is not None and v > self.ch.alarm_high)
- )
- if alarm != self._alarm:
- self._alarm = alarm
- self.setProperty("alarm", alarm)
- self.style().unpolish(self)
- self.style().polish(self)
-
-
-class ReadoutPanel(QWidget):
- def __init__(self, registry: DeviceRegistry):
- super().__init__()
- self.registry = registry
- self._readouts = {} # (dev_id, ch_id) -> ChannelReadout
- self._build()
- self.refresh()
-
- def _build(self):
- layout = QVBoxLayout(self)
- layout.setContentsMargins(0, 0, 0, 0)
- layout.setSpacing(0)
-
- hdr = QLabel(" CHANNELS")
- hdr.setObjectName("panelHeader")
- hdr.setMinimumHeight(28)
- layout.addWidget(hdr)
-
- scroll = QScrollArea()
- scroll.setWidgetResizable(True)
- scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
-
- self._container = QWidget()
- self._inner = QVBoxLayout(self._container)
- self._inner.setContentsMargins(6, 6, 6, 6)
- self._inner.setSpacing(4)
- self._inner.addStretch()
-
- scroll.setWidget(self._container)
- layout.addWidget(scroll)
-
- def refresh(self):
- for w in self._readouts.values():
- w.deleteLater()
- self._readouts.clear()
- # Clear layout
- while self._inner.count() > 1:
- item = self._inner.takeAt(0)
- if item and item.widget():
- item.widget().deleteLater()
-
- for dev in self.registry.all_instances():
- for ch in dev.info.channels:
- if not ch.enabled:
- continue
- ro = ChannelReadout(ch)
- self._inner.insertWidget(self._inner.count() - 1, ro)
- self._readouts[(dev.info.device_id, ch.channel_id)] = ro
-
- @pyqtSlot(str, str, float, float)
- def on_new_data(self, dev_id: str, ch_id: str, _ts: float, value: float):
- ro = self._readouts.get((dev_id, ch_id))
- if ro:
- ro.update_value(value)
diff --git a/ui/style_dark.qss b/ui/style_dark.qss
index 4043ff6..df48330 100644
--- a/ui/style_dark.qss
+++ b/ui/style_dark.qss
@@ -464,3 +464,32 @@ QLabel#traceSource {
font-family: "IBM Plex Mono", monospace;
font-size: 12px;
}
+
+/* ── Profile / File menu ─────────────────────────────────────────── */
+QMenu#profileMenu {
+ background-color: #111620;
+ border: 1px solid #2a3558;
+ border-radius: 4px;
+ padding: 4px 0;
+ color: #e2e8f0;
+ font-size: 13px;
+}
+QMenu#profileMenu::item {
+ padding: 7px 20px;
+ color: #c7d2fe;
+}
+QMenu#profileMenu::item:selected {
+ background-color: #1e3a5f;
+ color: #e0f2fe;
+}
+QMenu#profileMenu::separator {
+ height: 1px;
+ background: #2a3558;
+ margin: 3px 8px;
+}
+
+/* ── Control widget wrapper (edit/remove row) ────────────────────── */
+QFrame#controlWidgetWrapper {
+ background: transparent;
+ border: none;
+}
diff --git a/ui/windows/__pycache__/__init__.cpython-312.pyc b/ui/windows/__pycache__/__init__.cpython-312.pyc
new file mode 100644
index 0000000..244f840
--- /dev/null
+++ b/ui/windows/__pycache__/__init__.cpython-312.pyc
Binary files differ
diff --git a/ui/windows/__pycache__/__init__.cpython-314.pyc b/ui/windows/__pycache__/__init__.cpython-314.pyc
deleted file mode 100644
index 0f65045..0000000
--- a/ui/windows/__pycache__/__init__.cpython-314.pyc
+++ /dev/null
Binary files differ
diff --git a/ui/windows/__pycache__/devices_window.cpython-312.pyc b/ui/windows/__pycache__/devices_window.cpython-312.pyc
new file mode 100644
index 0000000..f2ce20a
--- /dev/null
+++ b/ui/windows/__pycache__/devices_window.cpython-312.pyc
Binary files differ
diff --git a/ui/windows/__pycache__/devices_window.cpython-314.pyc b/ui/windows/__pycache__/devices_window.cpython-314.pyc
deleted file mode 100644
index f13d7e0..0000000
--- a/ui/windows/__pycache__/devices_window.cpython-314.pyc
+++ /dev/null
Binary files differ
diff --git a/ui/windows/__pycache__/plot_window.cpython-312.pyc b/ui/windows/__pycache__/plot_window.cpython-312.pyc
new file mode 100644
index 0000000..5c957ed
--- /dev/null
+++ b/ui/windows/__pycache__/plot_window.cpython-312.pyc
Binary files differ
diff --git a/ui/windows/__pycache__/plot_window.cpython-314.pyc b/ui/windows/__pycache__/plot_window.cpython-314.pyc
deleted file mode 100644
index a76c9b5..0000000
--- a/ui/windows/__pycache__/plot_window.cpython-314.pyc
+++ /dev/null
Binary files differ
diff --git a/ui/windows/__pycache__/settings_window.cpython-312.pyc b/ui/windows/__pycache__/settings_window.cpython-312.pyc
new file mode 100644
index 0000000..7da4e1a
--- /dev/null
+++ b/ui/windows/__pycache__/settings_window.cpython-312.pyc
Binary files differ
diff --git a/ui/windows/__pycache__/settings_window.cpython-314.pyc b/ui/windows/__pycache__/settings_window.cpython-314.pyc
deleted file mode 100644
index 38f2969..0000000
--- a/ui/windows/__pycache__/settings_window.cpython-314.pyc
+++ /dev/null
Binary files differ
diff --git a/ui/windows/__pycache__/signals_window.cpython-312.pyc b/ui/windows/__pycache__/signals_window.cpython-312.pyc
new file mode 100644
index 0000000..6b423d8
--- /dev/null
+++ b/ui/windows/__pycache__/signals_window.cpython-312.pyc
Binary files differ
diff --git a/ui/windows/__pycache__/signals_window.cpython-314.pyc b/ui/windows/__pycache__/signals_window.cpython-314.pyc
deleted file mode 100644
index 1ca83d8..0000000
--- a/ui/windows/__pycache__/signals_window.cpython-314.pyc
+++ /dev/null
Binary files differ