diff options
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 Binary files differnew file mode 100644 index 0000000..8aa354b --- /dev/null +++ b/api_layers/__pycache__/__init__.cpython-312.pyc diff --git a/api_layers/__pycache__/__init__.cpython-314.pyc b/api_layers/__pycache__/__init__.cpython-314.pyc Binary files differdeleted file mode 100644 index 01ed696..0000000 --- a/api_layers/__pycache__/__init__.cpython-314.pyc +++ /dev/null diff --git a/api_layers/__pycache__/arduino_layer.cpython-312.pyc b/api_layers/__pycache__/arduino_layer.cpython-312.pyc Binary files differnew file mode 100644 index 0000000..f6c3ff9 --- /dev/null +++ b/api_layers/__pycache__/arduino_layer.cpython-312.pyc diff --git a/api_layers/__pycache__/arduino_layer.cpython-314.pyc b/api_layers/__pycache__/arduino_layer.cpython-314.pyc Binary files differdeleted file mode 100644 index c1b7e5b..0000000 --- a/api_layers/__pycache__/arduino_layer.cpython-314.pyc +++ /dev/null diff --git a/api_layers/__pycache__/nidaqmx_layer.cpython-312.pyc b/api_layers/__pycache__/nidaqmx_layer.cpython-312.pyc Binary files differnew file mode 100644 index 0000000..de2c034 --- /dev/null +++ b/api_layers/__pycache__/nidaqmx_layer.cpython-312.pyc diff --git a/api_layers/__pycache__/nidaqmx_layer.cpython-314.pyc b/api_layers/__pycache__/nidaqmx_layer.cpython-314.pyc Binary files differdeleted file mode 100644 index e028b5e..0000000 --- a/api_layers/__pycache__/nidaqmx_layer.cpython-314.pyc +++ /dev/null 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 Binary files differnew file mode 100644 index 0000000..36a9dda --- /dev/null +++ b/core/__pycache__/__init__.cpython-312.pyc diff --git a/core/__pycache__/__init__.cpython-314.pyc b/core/__pycache__/__init__.cpython-314.pyc Binary files differdeleted file mode 100644 index 48b27f8..0000000 --- a/core/__pycache__/__init__.cpython-314.pyc +++ /dev/null diff --git a/core/__pycache__/acquisition.cpython-312.pyc b/core/__pycache__/acquisition.cpython-312.pyc Binary files differnew file mode 100644 index 0000000..3d036ae --- /dev/null +++ b/core/__pycache__/acquisition.cpython-312.pyc diff --git a/core/__pycache__/acquisition.cpython-314.pyc b/core/__pycache__/acquisition.cpython-314.pyc Binary files differdeleted file mode 100644 index 023a11f..0000000 --- a/core/__pycache__/acquisition.cpython-314.pyc +++ /dev/null diff --git a/core/__pycache__/profile.cpython-312.pyc b/core/__pycache__/profile.cpython-312.pyc Binary files differnew file mode 100644 index 0000000..8637365 --- /dev/null +++ b/core/__pycache__/profile.cpython-312.pyc diff --git a/core/__pycache__/signal_processor.cpython-312.pyc b/core/__pycache__/signal_processor.cpython-312.pyc Binary files differnew file mode 100644 index 0000000..fe7bba0 --- /dev/null +++ b/core/__pycache__/signal_processor.cpython-312.pyc diff --git a/core/__pycache__/signal_processor.cpython-314.pyc b/core/__pycache__/signal_processor.cpython-314.pyc Binary files differdeleted file mode 100644 index 4b1ee4e..0000000 --- a/core/__pycache__/signal_processor.cpython-314.pyc +++ /dev/null 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 Binary files differnew file mode 100644 index 0000000..bd9bb71 --- /dev/null +++ b/devices/__pycache__/__init__.cpython-312.pyc diff --git a/devices/__pycache__/__init__.cpython-314.pyc b/devices/__pycache__/__init__.cpython-314.pyc Binary files differdeleted file mode 100644 index 47d46e4..0000000 --- a/devices/__pycache__/__init__.cpython-314.pyc +++ /dev/null diff --git a/devices/__pycache__/analog_input.cpython-312.pyc b/devices/__pycache__/analog_input.cpython-312.pyc Binary files differnew file mode 100644 index 0000000..2ff8a31 --- /dev/null +++ b/devices/__pycache__/analog_input.cpython-312.pyc diff --git a/devices/__pycache__/analog_input.cpython-314.pyc b/devices/__pycache__/analog_input.cpython-314.pyc Binary files differdeleted file mode 100644 index b6be747..0000000 --- a/devices/__pycache__/analog_input.cpython-314.pyc +++ /dev/null diff --git a/devices/__pycache__/base_device.cpython-312.pyc b/devices/__pycache__/base_device.cpython-312.pyc Binary files differnew file mode 100644 index 0000000..f665ff1 --- /dev/null +++ b/devices/__pycache__/base_device.cpython-312.pyc diff --git a/devices/__pycache__/base_device.cpython-314.pyc b/devices/__pycache__/base_device.cpython-314.pyc Binary files differdeleted file mode 100644 index 6566690..0000000 --- a/devices/__pycache__/base_device.cpython-314.pyc +++ /dev/null diff --git a/devices/__pycache__/device_registry.cpython-312.pyc b/devices/__pycache__/device_registry.cpython-312.pyc Binary files differnew file mode 100644 index 0000000..2694da3 --- /dev/null +++ b/devices/__pycache__/device_registry.cpython-312.pyc diff --git a/devices/__pycache__/device_registry.cpython-314.pyc b/devices/__pycache__/device_registry.cpython-314.pyc Binary files differdeleted file mode 100644 index 780bd71..0000000 --- a/devices/__pycache__/device_registry.cpython-314.pyc +++ /dev/null diff --git a/devices/__pycache__/digital_io.cpython-312.pyc b/devices/__pycache__/digital_io.cpython-312.pyc Binary files differnew file mode 100644 index 0000000..8c7375b --- /dev/null +++ b/devices/__pycache__/digital_io.cpython-312.pyc diff --git a/devices/__pycache__/digital_io.cpython-314.pyc b/devices/__pycache__/digital_io.cpython-314.pyc Binary files differdeleted file mode 100644 index c1a14b4..0000000 --- a/devices/__pycache__/digital_io.cpython-314.pyc +++ /dev/null diff --git a/devices/__pycache__/serial_device.cpython-312.pyc b/devices/__pycache__/serial_device.cpython-312.pyc Binary files differnew file mode 100644 index 0000000..7472878 --- /dev/null +++ b/devices/__pycache__/serial_device.cpython-312.pyc diff --git a/devices/__pycache__/serial_device.cpython-314.pyc b/devices/__pycache__/serial_device.cpython-314.pyc Binary files differdeleted file mode 100644 index 46fb630..0000000 --- a/devices/__pycache__/serial_device.cpython-314.pyc +++ /dev/null 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 Binary files differdeleted file mode 100644 index 1094f27..0000000 --- a/plot.png +++ /dev/null diff --git a/ui/__pycache__/__init__.cpython-312.pyc b/ui/__pycache__/__init__.cpython-312.pyc Binary files differnew file mode 100644 index 0000000..6ed2349 --- /dev/null +++ b/ui/__pycache__/__init__.cpython-312.pyc diff --git a/ui/__pycache__/__init__.cpython-314.pyc b/ui/__pycache__/__init__.cpython-314.pyc Binary files differdeleted file mode 100644 index 3af9695..0000000 --- a/ui/__pycache__/__init__.cpython-314.pyc +++ /dev/null diff --git a/ui/__pycache__/add_device_dialog.cpython-314.pyc b/ui/__pycache__/add_device_dialog.cpython-314.pyc Binary files differdeleted file mode 100644 index e74884b..0000000 --- a/ui/__pycache__/add_device_dialog.cpython-314.pyc +++ /dev/null diff --git a/ui/__pycache__/alarm_panel.cpython-314.pyc b/ui/__pycache__/alarm_panel.cpython-314.pyc Binary files differdeleted file mode 100644 index a1811f7..0000000 --- a/ui/__pycache__/alarm_panel.cpython-314.pyc +++ /dev/null diff --git a/ui/__pycache__/config_dialog.cpython-314.pyc b/ui/__pycache__/config_dialog.cpython-314.pyc Binary files differdeleted file mode 100644 index 788f4a0..0000000 --- a/ui/__pycache__/config_dialog.cpython-314.pyc +++ /dev/null diff --git a/ui/__pycache__/control_editor.cpython-312.pyc b/ui/__pycache__/control_editor.cpython-312.pyc Binary files differnew file mode 100644 index 0000000..f1716c6 --- /dev/null +++ b/ui/__pycache__/control_editor.cpython-312.pyc diff --git a/ui/__pycache__/control_panel.cpython-312.pyc b/ui/__pycache__/control_panel.cpython-312.pyc Binary files differnew file mode 100644 index 0000000..c54cd25 --- /dev/null +++ b/ui/__pycache__/control_panel.cpython-312.pyc diff --git a/ui/__pycache__/control_panel.cpython-314.pyc b/ui/__pycache__/control_panel.cpython-314.pyc Binary files differdeleted file mode 100644 index a534649..0000000 --- a/ui/__pycache__/control_panel.cpython-314.pyc +++ /dev/null diff --git a/ui/__pycache__/device_panel.cpython-314.pyc b/ui/__pycache__/device_panel.cpython-314.pyc Binary files differdeleted file mode 100644 index 07eb750..0000000 --- a/ui/__pycache__/device_panel.cpython-314.pyc +++ /dev/null diff --git a/ui/__pycache__/main_window.cpython-312.pyc b/ui/__pycache__/main_window.cpython-312.pyc Binary files differnew file mode 100644 index 0000000..b99d7c2 --- /dev/null +++ b/ui/__pycache__/main_window.cpython-312.pyc diff --git a/ui/__pycache__/main_window.cpython-314.pyc b/ui/__pycache__/main_window.cpython-314.pyc Binary files differdeleted file mode 100644 index d933966..0000000 --- a/ui/__pycache__/main_window.cpython-314.pyc +++ /dev/null diff --git a/ui/__pycache__/profile_manager_ui.cpython-312.pyc b/ui/__pycache__/profile_manager_ui.cpython-312.pyc Binary files differnew file mode 100644 index 0000000..e66a754 --- /dev/null +++ b/ui/__pycache__/profile_manager_ui.cpython-312.pyc diff --git a/ui/__pycache__/readout_panel.cpython-314.pyc b/ui/__pycache__/readout_panel.cpython-314.pyc Binary files differdeleted file mode 100644 index 9258b10..0000000 --- a/ui/__pycache__/readout_panel.cpython-314.pyc +++ /dev/null diff --git a/ui/__pycache__/strip_chart.cpython-312.pyc b/ui/__pycache__/strip_chart.cpython-312.pyc Binary files differnew file mode 100644 index 0000000..ba3ca5d --- /dev/null +++ b/ui/__pycache__/strip_chart.cpython-312.pyc diff --git a/ui/__pycache__/strip_chart.cpython-314.pyc b/ui/__pycache__/strip_chart.cpython-314.pyc Binary files differdeleted file mode 100644 index af41096..0000000 --- a/ui/__pycache__/strip_chart.cpython-314.pyc +++ /dev/null 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 Binary files differnew file mode 100644 index 0000000..244f840 --- /dev/null +++ b/ui/windows/__pycache__/__init__.cpython-312.pyc diff --git a/ui/windows/__pycache__/__init__.cpython-314.pyc b/ui/windows/__pycache__/__init__.cpython-314.pyc Binary files differdeleted file mode 100644 index 0f65045..0000000 --- a/ui/windows/__pycache__/__init__.cpython-314.pyc +++ /dev/null diff --git a/ui/windows/__pycache__/devices_window.cpython-312.pyc b/ui/windows/__pycache__/devices_window.cpython-312.pyc Binary files differnew file mode 100644 index 0000000..f2ce20a --- /dev/null +++ b/ui/windows/__pycache__/devices_window.cpython-312.pyc diff --git a/ui/windows/__pycache__/devices_window.cpython-314.pyc b/ui/windows/__pycache__/devices_window.cpython-314.pyc Binary files differdeleted file mode 100644 index f13d7e0..0000000 --- a/ui/windows/__pycache__/devices_window.cpython-314.pyc +++ /dev/null diff --git a/ui/windows/__pycache__/plot_window.cpython-312.pyc b/ui/windows/__pycache__/plot_window.cpython-312.pyc Binary files differnew file mode 100644 index 0000000..5c957ed --- /dev/null +++ b/ui/windows/__pycache__/plot_window.cpython-312.pyc diff --git a/ui/windows/__pycache__/plot_window.cpython-314.pyc b/ui/windows/__pycache__/plot_window.cpython-314.pyc Binary files differdeleted file mode 100644 index a76c9b5..0000000 --- a/ui/windows/__pycache__/plot_window.cpython-314.pyc +++ /dev/null diff --git a/ui/windows/__pycache__/settings_window.cpython-312.pyc b/ui/windows/__pycache__/settings_window.cpython-312.pyc Binary files differnew file mode 100644 index 0000000..7da4e1a --- /dev/null +++ b/ui/windows/__pycache__/settings_window.cpython-312.pyc diff --git a/ui/windows/__pycache__/settings_window.cpython-314.pyc b/ui/windows/__pycache__/settings_window.cpython-314.pyc Binary files differdeleted file mode 100644 index 38f2969..0000000 --- a/ui/windows/__pycache__/settings_window.cpython-314.pyc +++ /dev/null diff --git a/ui/windows/__pycache__/signals_window.cpython-312.pyc b/ui/windows/__pycache__/signals_window.cpython-312.pyc Binary files differnew file mode 100644 index 0000000..6b423d8 --- /dev/null +++ b/ui/windows/__pycache__/signals_window.cpython-312.pyc diff --git a/ui/windows/__pycache__/signals_window.cpython-314.pyc b/ui/windows/__pycache__/signals_window.cpython-314.pyc Binary files differdeleted file mode 100644 index 1ca83d8..0000000 --- a/ui/windows/__pycache__/signals_window.cpython-314.pyc +++ /dev/null |
