diff options
| author | Christian Kolset <christian.kolset@gmail.com> | 2026-04-27 16:53:14 -0600 |
|---|---|---|
| committer | Christian Kolset <christian.kolset@gmail.com> | 2026-04-27 16:53:14 -0600 |
| commit | 2b9943ba0d28449a590acfa8a41555b174b6ba84 (patch) | |
| tree | e8bef8c9bc61dd7e2f9c8a9b177058a4b09546db | |
| parent | 67cfa0a514c7de4605ed7360e15a81aa781e510e (diff) | |
Improved arduino interface. Now allowing the user to configure pins on the fly.
68 files changed, 1184 insertions, 329 deletions
diff --git a/api_layers/__pycache__/__init__.cpython-312.pyc b/api_layers/__pycache__/__init__.cpython-312.pyc Binary files differindex 689f52e..a9a035b 100644 --- a/api_layers/__pycache__/__init__.cpython-312.pyc +++ 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 differindex d30ccf5..01ab34b 100644 --- a/api_layers/__pycache__/__init__.cpython-314.pyc +++ b/api_layers/__pycache__/__init__.cpython-314.pyc diff --git a/api_layers/__pycache__/arduino_layer.cpython-312.pyc b/api_layers/__pycache__/arduino_layer.cpython-312.pyc Binary files differindex a0191ad..cb2dd3b 100644 --- a/api_layers/__pycache__/arduino_layer.cpython-312.pyc +++ 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 differindex 1cfec4e..769e4f0 100644 --- a/api_layers/__pycache__/arduino_layer.cpython-314.pyc +++ b/api_layers/__pycache__/arduino_layer.cpython-314.pyc diff --git a/api_layers/__pycache__/nidaqmx_layer.cpython-312.pyc b/api_layers/__pycache__/nidaqmx_layer.cpython-312.pyc Binary files differindex d310f54..8dda62e 100644 --- a/api_layers/__pycache__/nidaqmx_layer.cpython-312.pyc +++ 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 differindex 6902c49..c11967d 100644 --- a/api_layers/__pycache__/nidaqmx_layer.cpython-314.pyc +++ b/api_layers/__pycache__/nidaqmx_layer.cpython-314.pyc diff --git a/api_layers/__pycache__/port_registry.cpython-312.pyc b/api_layers/__pycache__/port_registry.cpython-312.pyc Binary files differindex 48e7384..088d6d3 100644 --- a/api_layers/__pycache__/port_registry.cpython-312.pyc +++ b/api_layers/__pycache__/port_registry.cpython-312.pyc diff --git a/api_layers/__pycache__/port_registry.cpython-314.pyc b/api_layers/__pycache__/port_registry.cpython-314.pyc Binary files differnew file mode 100644 index 0000000..3ff0ab0 --- /dev/null +++ b/api_layers/__pycache__/port_registry.cpython-314.pyc diff --git a/api_layers/arduino_layer.py b/api_layers/arduino_layer.py index fed54f9..4f8cee4 100644 --- a/api_layers/arduino_layer.py +++ b/api_layers/arduino_layer.py @@ -27,6 +27,16 @@ PC → Arduino (commands, newline-terminated): C:KP:1.2 C:MODE:1 + DPIN:IN:<pins> Configure digital input pins (CSV pin numbers, no 'D' prefix) + DPIN:IN:2,3,8 → set DI pins to 2,3,8; sets N_DIG_IN=3 + + DPIN:OUT:<pins> Configure digital output pins (CSV pin numbers, no 'D' prefix) + DPIN:OUT:5,6,13 → set DO pins to 5,6,13; sets N_DIG_OUT=3 + + APIN:<indices> Configure analog input pins (CSV indices 0–5 into A0–A5) + APIN:0,1,3 → read A0,A1,A3; sets N_ANALOG=3 + Output labels use actual indices: A0:val,A1:val,A3:val + Q:<name> Request current reading by name (Arduino replies immediately) Q:TEMP → Arduino sends TEMP:23.45\n @@ -75,6 +85,9 @@ class ArduinoLayer: DEFAULT_ANALOG_PINS = ["A0", "A1", "A2", "A3", "A4", "A5"] DEFAULT_DIGITAL_PINS = ["D2", "D3", "D4", "D5", "D6", "D7"] + # Firmware DIGITAL_IN[] and DIGITAL_OUT[] arrays — match arduino_layer.py firmware sketch + DEFAULT_DI_PINS = ["D2", "D3", "D4"] + DEFAULT_DO_PINS = ["D5", "D6", "D7", "D9", "D10", "D11"] def __init__( self, @@ -262,6 +275,48 @@ class ArduinoLayer: """Reset all outputs to default state. Sends: X:RESET\n""" return self._send("X:RESET") + def configure_pins( + self, + di_pins: List[str] = None, + do_pins: List[str] = None, + analog_pins: List[str] = None, + ) -> bool: + """ + Send pin-configuration commands so the firmware activates the requested channels. + No-op in simulation mode. Commands are deferred 1 s to allow Arduino boot time. + + di_pins: ["D2","D3","D8"] — sends DPIN:IN:2,3,8 + do_pins: ["D5","D6","D9"] — sends DPIN:OUT:5,6,9 + analog_pins: ["A0","A1","A3"] — sends APIN:0,1,3 + """ + if self.simulate: + return True + + def _dnum(p: str) -> str: + return p[1:] if p.upper().startswith("D") else p + + def _anum(p: str) -> str: + return p[1:] if p.upper().startswith("A") else p + + cmds = [] + if di_pins: + cmds.append(f"DPIN:IN:{','.join(_dnum(p) for p in di_pins)}") + if do_pins: + cmds.append(f"DPIN:OUT:{','.join(_dnum(p) for p in do_pins)}") + if analog_pins: + cmds.append(f"APIN:{','.join(_anum(p) for p in analog_pins)}") + + if not cmds: + return True + + def _deferred(): + time.sleep(1.0) + for cmd in cmds: + self._send(cmd) + + threading.Thread(target=_deferred, daemon=True, name="PinInit").start() + return True + # ── Legacy compat ───────────────────────────────────────────────────── def write(self, pin: str, value: int) -> bool: @@ -546,12 +601,13 @@ ARDUINO_FIRMWARE = r""" */ // ── 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 = 6; // ← set to how many digital outputs you use +int ANALOG_PINS[6] = {A0, A1, A2, A3, A4, A5}; // actual pin numbers (reconfigured by APIN:) +int ANALOG_IDX[6] = {0, 1, 2, 3, 4, 5}; // label indices used in output (A0, A1, …) +int DIGITAL_IN[16] = {2, 3, 4}; // reconfigured at runtime via DPIN:IN: +int DIGITAL_OUT[16] = {5, 6, 7, 9, 10, 11}; // reconfigured via DPIN:OUT: +int N_ANALOG = 0; // set by APIN: command (0 = inactive until configured by PC) +int N_DIG_IN = 0; // set by DPIN:IN: command (0 = inactive until configured) +int N_DIG_OUT = 0; // set by DPIN:OUT: command (0 = inactive until configured) const int SEND_INTERVAL = 50; // ms between data frames (50 = 20 Hz) const long BAUD_RATE = 115200; @@ -652,6 +708,57 @@ void handleCommands() { Serial.print("ACK:"); Serial.println(cmd); } + // APIN:0,1,3 — reconfigure analog inputs at runtime (indices 0–5 into A0–A5) + else if (type == 'A') { + if (!cmd.startsWith("APIN:")) { Serial.println("ERR:Bad A command"); return; } + String pinList = cmd.substring(5); // "0,1,3" + const int _APINS[] = {A0,A1,A2,A3,A4,A5}; + N_ANALOG = 0; + int start = 0; + for (int i = 0; i <= (int)pinList.length(); i++) { + if (i == (int)pinList.length() || pinList[i] == ',') { + String tok = pinList.substring(start, i); tok.trim(); + if (tok.length() > 0 && N_ANALOG < 6) { + int idx = tok.toInt(); + if (idx >= 0 && idx < 6) { + ANALOG_PINS[N_ANALOG] = _APINS[idx]; + ANALOG_IDX[N_ANALOG] = idx; + N_ANALOG++; + } + } + start = i + 1; + } + } + Serial.print("ACK:"); Serial.println(cmd); + } + + // DPIN:IN:2,3,8 or DPIN:OUT:5,6,7 — reconfigure digital I/O pins at runtime + else if (type == 'D') { + int c1 = cmd.indexOf(':', 2); + int c2 = (c1 >= 0) ? cmd.indexOf(':', c1 + 1) : -1; + if (c1 < 0 || c2 < 0) { Serial.println("ERR:Bad DPIN format"); return; } + String dir = cmd.substring(2, c1); // "IN" or "OUT" + String pinList = cmd.substring(c2 + 1); // "2,3,8" + bool isIn = (dir == "IN"); + int* arr = isIn ? DIGITAL_IN : DIGITAL_OUT; + int* cnt = isIn ? &N_DIG_IN : &N_DIG_OUT; + int mode = isIn ? INPUT_PULLUP : OUTPUT; + *cnt = 0; + int start = 0; + for (int i = 0; i <= (int)pinList.length(); i++) { + if (i == (int)pinList.length() || pinList[i] == ',') { + String tok = pinList.substring(start, i); tok.trim(); + if (tok.length() > 0 && *cnt < 16) { + arr[*cnt] = tok.toInt(); + pinMode(arr[*cnt], mode); + (*cnt)++; + } + start = i + 1; + } + } + Serial.print("ACK:"); Serial.println(cmd); + } + else { Serial.print("ERR:Unknown command: "); Serial.println(cmd); } @@ -671,7 +778,7 @@ void sendFrame() { for (int i = 0; i < N_ANALOG; i++) { float v = analogRead(ANALOG_PINS[i]) * (5.0 / 1023.0); if (!first) out += ","; - out += "A" + String(i) + ":" + String(v, 3); + out += "A" + String(ANALOG_IDX[i]) + ":" + String(v, 3); first = false; } // Digital inputs (INPUT_PULLUP — invert so pressed=1) diff --git a/core/__pycache__/__init__.cpython-312.pyc b/core/__pycache__/__init__.cpython-312.pyc Binary files differindex 79fc26c..91f5de3 100644 --- a/core/__pycache__/__init__.cpython-312.pyc +++ b/core/__pycache__/__init__.cpython-312.pyc diff --git a/core/__pycache__/acquisition.cpython-312.pyc b/core/__pycache__/acquisition.cpython-312.pyc Binary files differindex d1b61fc..cb5f3f3 100644 --- a/core/__pycache__/acquisition.cpython-312.pyc +++ 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 differindex 8a48b96..1b9ff56 100644 --- a/core/__pycache__/acquisition.cpython-314.pyc +++ b/core/__pycache__/acquisition.cpython-314.pyc diff --git a/core/__pycache__/profile.cpython-312.pyc b/core/__pycache__/profile.cpython-312.pyc Binary files differindex 9105704..162a593 100644 --- a/core/__pycache__/profile.cpython-312.pyc +++ b/core/__pycache__/profile.cpython-312.pyc diff --git a/core/__pycache__/profile.cpython-314.pyc b/core/__pycache__/profile.cpython-314.pyc Binary files differindex f009c3f..b790fac 100644 --- a/core/__pycache__/profile.cpython-314.pyc +++ b/core/__pycache__/profile.cpython-314.pyc diff --git a/core/__pycache__/signal_processor.cpython-312.pyc b/core/__pycache__/signal_processor.cpython-312.pyc Binary files differindex 505a573..4572e55 100644 --- a/core/__pycache__/signal_processor.cpython-312.pyc +++ 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 differindex 4a945a6..9076bcf 100644 --- a/core/__pycache__/signal_processor.cpython-314.pyc +++ b/core/__pycache__/signal_processor.cpython-314.pyc diff --git a/core/profile.py b/core/profile.py index ef8c55b..d21bf2f 100644 --- a/core/profile.py +++ b/core/profile.py @@ -216,13 +216,13 @@ class ProfileManager: # ── Devices ────────────────────────────────────────────────────── _DEVICE_FACTORIES = {} try: - from devices.analog_input import AnalogInputDevice - _DEVICE_FACTORIES["analog_input"] = AnalogInputDevice + from devices.arduino_device import ArduinoDevice + _DEVICE_FACTORIES["arduino"] = ArduinoDevice except Exception: pass try: - from devices.digital_io import DigitalIODevice - _DEVICE_FACTORIES["digital_io"] = DigitalIODevice + from devices.nidaqmx_device import NidaqmxDevice + _DEVICE_FACTORIES["nidaqmx"] = NidaqmxDevice except Exception: pass try: @@ -230,6 +230,17 @@ class ProfileManager: _DEVICE_FACTORIES["serial"] = SerialDevice except Exception: pass + # Backward compatibility with old profiles + try: + from devices.analog_input import AnalogInputDevice + _DEVICE_FACTORIES["analog_input"] = AnalogInputDevice + except Exception: + pass + try: + from devices.digital_io import DigitalIODevice + _DEVICE_FACTORIES["digital_io"] = DigitalIODevice + except Exception: + pass # Clear all existing devices — profile defines the complete device set for dev in list(registry.all_instances()): diff --git a/devices/__pycache__/__init__.cpython-312.pyc b/devices/__pycache__/__init__.cpython-312.pyc Binary files differindex 88e8062..0e0929f 100644 --- a/devices/__pycache__/__init__.cpython-312.pyc +++ 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 differindex fb1a2a7..e06c626 100644 --- a/devices/__pycache__/__init__.cpython-314.pyc +++ b/devices/__pycache__/__init__.cpython-314.pyc diff --git a/devices/__pycache__/analog_input.cpython-312.pyc b/devices/__pycache__/analog_input.cpython-312.pyc Binary files differindex 931b605..f22ffd6 100644 --- a/devices/__pycache__/analog_input.cpython-312.pyc +++ 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 differindex d82bc07..fe88c04 100644 --- a/devices/__pycache__/analog_input.cpython-314.pyc +++ b/devices/__pycache__/analog_input.cpython-314.pyc diff --git a/devices/__pycache__/arduino_device.cpython-312.pyc b/devices/__pycache__/arduino_device.cpython-312.pyc Binary files differnew file mode 100644 index 0000000..7c8c5d8 --- /dev/null +++ b/devices/__pycache__/arduino_device.cpython-312.pyc diff --git a/devices/__pycache__/arduino_device.cpython-314.pyc b/devices/__pycache__/arduino_device.cpython-314.pyc Binary files differnew file mode 100644 index 0000000..531488c --- /dev/null +++ b/devices/__pycache__/arduino_device.cpython-314.pyc diff --git a/devices/__pycache__/base_device.cpython-312.pyc b/devices/__pycache__/base_device.cpython-312.pyc Binary files differindex d5604d7..087e500 100644 --- a/devices/__pycache__/base_device.cpython-312.pyc +++ 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 differindex fa99267..b8f94f4 100644 --- a/devices/__pycache__/base_device.cpython-314.pyc +++ b/devices/__pycache__/base_device.cpython-314.pyc diff --git a/devices/__pycache__/device_registry.cpython-312.pyc b/devices/__pycache__/device_registry.cpython-312.pyc Binary files differindex 276f1e2..3495e35 100644 --- a/devices/__pycache__/device_registry.cpython-312.pyc +++ 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 differindex 6912654..f3f7d3e 100644 --- a/devices/__pycache__/device_registry.cpython-314.pyc +++ b/devices/__pycache__/device_registry.cpython-314.pyc diff --git a/devices/__pycache__/digital_io.cpython-312.pyc b/devices/__pycache__/digital_io.cpython-312.pyc Binary files differindex c0262d5..546f9db 100644 --- a/devices/__pycache__/digital_io.cpython-312.pyc +++ 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 differindex d51aaf5..e0fc182 100644 --- a/devices/__pycache__/digital_io.cpython-314.pyc +++ b/devices/__pycache__/digital_io.cpython-314.pyc diff --git a/devices/__pycache__/nidaqmx_device.cpython-312.pyc b/devices/__pycache__/nidaqmx_device.cpython-312.pyc Binary files differnew file mode 100644 index 0000000..04f8f94 --- /dev/null +++ b/devices/__pycache__/nidaqmx_device.cpython-312.pyc diff --git a/devices/__pycache__/nidaqmx_device.cpython-314.pyc b/devices/__pycache__/nidaqmx_device.cpython-314.pyc Binary files differnew file mode 100644 index 0000000..27afd3f --- /dev/null +++ b/devices/__pycache__/nidaqmx_device.cpython-314.pyc diff --git a/devices/__pycache__/serial_device.cpython-312.pyc b/devices/__pycache__/serial_device.cpython-312.pyc Binary files differindex e885b40..b61267b 100644 --- a/devices/__pycache__/serial_device.cpython-312.pyc +++ 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 differindex 976e575..3263217 100644 --- a/devices/__pycache__/serial_device.cpython-314.pyc +++ b/devices/__pycache__/serial_device.cpython-314.pyc diff --git a/devices/arduino_device.py b/devices/arduino_device.py new file mode 100644 index 0000000..7c1d21b --- /dev/null +++ b/devices/arduino_device.py @@ -0,0 +1,423 @@ +""" +devices/arduino_device.py + +Combined Arduino physical device — analog inputs + digital I/O on one board. +Uses port_registry so the single ArduinoLayer is shared between analog reads +and digital writes without opening the serial port twice. +""" + +from typing import Any, Dict, List, Optional + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QFormLayout, QGroupBox, + QComboBox, QCheckBox, QLineEdit, QLabel, QPushButton, + QListWidget, QListWidgetItem, QTextEdit, +) +from PyQt6.QtCore import Qt, QThread, pyqtSignal + +from devices.base_device import BaseDevice, ChannelConfig, DeviceInfo, DeviceStatus +from api_layers.arduino_layer import ArduinoLayer + +_ANALOG_COLORS = [ + "#00d4ff", "#ff6b35", "#7fff6e", "#ffcc00", + "#c77dff", "#ff4d6d", +] +_DI_COLORS = ["#4cc9f0", "#90e0ef", "#caf0f8", "#0077b6", "#023e8a", "#48cae4", "#ade8f4", "#00b4d8"] +_DO_COLORS = ["#ff6b35", "#ffcc00", "#f77f00", "#fcbf49", "#d62828", "#e63946", "#fb8500", "#ffd166"] + + +def _parse_pin_edit(text: str) -> List[str]: + """Parse "2,3,8" or "D2,D3,D8" → ["D2","D3","D8"]. Empty string → [].""" + pins = [] + for tok in text.split(","): + tok = tok.strip().upper() + if not tok: + continue + if not tok.startswith("D"): + tok = "D" + tok + pins.append(tok) + return pins + + +def _parse_analog_pin_edit(text: str) -> List[str]: + """Parse "A0,A1,A3" or "0,1,3" → ["A0","A1","A3"]. Empty string → [].""" + pins = [] + for tok in text.split(","): + tok = tok.strip().upper() + if not tok: + continue + if not tok.startswith("A"): + tok = "A" + tok + pins.append(tok) + return pins + + +class ArduinoDevice(BaseDevice): + DEVICE_TYPE = "arduino" + ICON = "⚡" + + def __init__( + self, + device_id: str = "ard_0", + analog_pins: Optional[List[str]] = None, + di_pins: Optional[List[str]] = None, + do_pins: Optional[List[str]] = None, + # backward compat — accepted when new list params not provided + num_analog: Optional[int] = None, + num_di: Optional[int] = None, + num_do: Optional[int] = None, + simulate: bool = True, + port: str = "COM3", + baud: int = 115200, + ): + self.simulate = simulate + self.backend = "arduino" + self._port = port + self._baud = baud + self._last_error = "" + + # Resolve each pin list — new list param wins, else fall back to count + if analog_pins is None: + n = num_analog if num_analog is not None else 4 + analog_pins = ArduinoLayer.DEFAULT_ANALOG_PINS[:n] + if di_pins is None: + n = num_di if num_di is not None else 2 + di_pins = ArduinoLayer.DEFAULT_DI_PINS[:n] + if do_pins is None: + n = num_do if num_do is not None else 4 + do_pins = ArduinoLayer.DEFAULT_DO_PINS[:n] + + self._analog_pins: List[str] = list(analog_pins) + self._di_pins: List[str] = list(di_pins) + self._do_pins: List[str] = list(do_pins) + + channels = [] + for i, pin in enumerate(self._analog_pins): + channels.append(ChannelConfig( + channel_id=pin, name=pin, unit="V", + min_value=0.0, max_value=5.0, + color=_ANALOG_COLORS[i % len(_ANALOG_COLORS)], + )) + for i in range(len(self._di_pins)): + channels.append(ChannelConfig( + channel_id=f"di{i}", name=f"DI {i}", unit="", + min_value=0.0, max_value=1.0, + color=_DI_COLORS[i % len(_DI_COLORS)], + )) + for i in range(len(self._do_pins)): + channels.append(ChannelConfig( + channel_id=f"do{i}", name=f"DO {i}", unit="", + min_value=0.0, max_value=1.0, + color=_DO_COLORS[i % len(_DO_COLORS)], + )) + + info = DeviceInfo( + device_id=device_id, + name="Arduino", + device_type=self.DEVICE_TYPE, + description=(f"Arduino — {len(self._analog_pins)} analog, " + f"{len(self._di_pins)} DI, {len(self._do_pins)} DO"), + manufacturer="Arduino", + icon=self.ICON, + channels=channels, + ) + super().__init__(info) + + self._output_state: Dict[str, int] = {f"do{i}": 0 for i in range(len(self._do_pins))} + self._di_state: Dict[str, int] = {f"di{i}": 0 for i in range(len(self._di_pins))} + + self._di_map = {f"di{i}": pin for i, pin in enumerate(self._di_pins)} + self._do_map = {f"do{i}": pin for i, pin in enumerate(self._do_pins)} + + self._layer = self._make_layer() + + def _make_layer(self): + from api_layers.port_registry import port_registry + return port_registry.get_layer( + port=self._port, + baud=self._baud, + simulate=self.simulate, + extra_pins=self._analog_pins, + ) + + # ── BaseDevice ──────────────────────────────────────────────────────────── + + def connect(self) -> bool: + self._last_error = "" + layer = self._layer + if layer.is_connected: + ok = True + else: + ok = layer.connect() + if not ok and hasattr(layer, "last_error"): + self._last_error = layer.last_error + if ok and not self.simulate: + layer.configure_pins( + di_pins=self._di_pins, + do_pins=self._do_pins, + analog_pins=self._analog_pins, + ) + self.status = DeviceStatus.SIMULATED if self.simulate else ( + DeviceStatus.CONNECTED if ok else DeviceStatus.ERROR + ) + return ok + + def disconnect(self) -> None: + if not self.simulate: + from api_layers.port_registry import port_registry + port_registry.release(self._port, self._baud) + self.status = DeviceStatus.DISCONNECTED + + def read_channels(self) -> Dict[str, float]: + raw = self._layer.read() + result = {} + for ch in self.info.channels: + cid = ch.channel_id + if cid in raw: + result[cid] = raw[cid] + elif cid.startswith("di"): + pin = self._di_map.get(cid) + if pin and pin in raw: + self._di_state[cid] = int(raw[pin]) + result[cid] = float(self._di_state.get(cid, 0)) + elif cid.startswith("do"): + result[cid] = float(self._output_state.get(cid, 0)) + return result + + def write_channel(self, channel_id: str, value: Any) -> bool: + cid = channel_id.strip() + if cid.startswith("do"): + self._output_state[cid] = int(bool(value)) + if not self.simulate and hasattr(self._layer, "digital_write"): + pin = self._do_map.get(cid) + if pin: + return self._layer.digital_write(pin, int(bool(value))) + return True + if cid.upper().startswith("D") and cid[1:].isdigit(): + return self._layer.digital_write(cid, int(bool(value))) if hasattr(self._layer, "digital_write") else False + if hasattr(self._layer, "set_parameter"): + return self._layer.set_parameter(cid, value) + return False + + def get_save_config(self) -> dict: + return { + "device_type": self.DEVICE_TYPE, + "device_id": self.info.device_id, + "analog_pins": self._analog_pins, + "di_pins": self._di_pins, + "do_pins": self._do_pins, + "simulate": self.simulate, + "port": self._port, + "baud": self._baud, + } + + def get_config_widget(self) -> QWidget: + return ArduinoConfigWidget(self) + + def switch_backend(self, simulate: bool, port: str, baud: int) -> None: + was_running = self.status in (DeviceStatus.CONNECTED, DeviceStatus.SIMULATED) + if was_running: + self.disconnect() + self.simulate = simulate + self._port = port + self._baud = baud + self._layer = self._make_layer() + if was_running: + self.connect() + + def remap_pins( + self, + analog_pins: List[str] = None, + di_pins: List[str] = None, + do_pins: List[str] = None, + ) -> None: + """Update active pin mapping and send config commands if hardware-connected.""" + if analog_pins is not None: + self._analog_pins = list(analog_pins) + if di_pins is not None: + self._di_pins = list(di_pins) + self._di_map = {f"di{i}": p for i, p in enumerate(di_pins)} + if do_pins is not None: + self._do_pins = list(do_pins) + self._do_map = {f"do{i}": p for i, p in enumerate(do_pins)} + if self.status == DeviceStatus.CONNECTED: + self._layer.configure_pins( + di_pins=self._di_pins, + do_pins=self._do_pins, + analog_pins=self._analog_pins, + ) + + +# ── Port scanner ────────────────────────────────────────────────────────────── + +class _PortScanThread(QThread): + done = pyqtSignal(list) + def run(self): + self.done.emit(ArduinoLayer.list_ports()) + + +# ── Config widget ───────────────────────────────────────────────────────────── + +class ArduinoConfigWidget(QWidget): + def __init__(self, device: ArduinoDevice): + super().__init__() + self.device = device + self._scanner = None + self._build() + + def _build(self): + root = QVBoxLayout(self) + root.setContentsMargins(10, 14, 10, 10) + root.setSpacing(12) + self.setMinimumWidth(420) + + # ── Serial settings ────────────────────────────────────────────── + ser_grp = QGroupBox("Serial Connection") + ser_form = QFormLayout(ser_grp) + ser_form.setContentsMargins(10, 16, 10, 10) + + self.port_edit = QLineEdit(self.device._port) + self.port_edit.setPlaceholderText("e.g. /dev/ttyUSB0 or COM3") + ser_form.addRow("Port:", self.port_edit) + + self.baud_cb = QComboBox() + self.baud_cb.addItems(["9600", "19200", "57600", "115200", "230400"]) + self.baud_cb.setCurrentText(str(self.device._baud)) + ser_form.addRow("Baud Rate:", self.baud_cb) + + self.sim_chk = QCheckBox("Simulation Mode (no hardware)") + self.sim_chk.setChecked(self.device.simulate) + ser_form.addRow(self.sim_chk) + + scan_row = QHBoxLayout() + self._scan_btn = QPushButton("🔍 Scan Ports") + self._scan_btn.setObjectName("addTraceBtn") + self._scan_btn.clicked.connect(self._scan_ports) + self._scan_lbl = QLabel("") + self._scan_lbl.setObjectName("traceSource") + scan_row.addWidget(self._scan_btn) + scan_row.addWidget(self._scan_lbl, 1) + ser_form.addRow(scan_row) + + self._port_list = QListWidget() + self._port_list.setObjectName("portList") + self._port_list.setMaximumHeight(90) + self._port_list.itemClicked.connect(self._on_port_selected) + ser_form.addRow(self._port_list) + + apply_btn = QPushButton("Apply & Reconnect") + apply_btn.setObjectName("applyButton") + apply_btn.clicked.connect(self._apply) + ser_form.addRow(apply_btn) + + root.addWidget(ser_grp) + + # ── Pin Mapping ─────────────────────────────────────────────────── + pin_grp = QGroupBox("Pin Mapping") + pin_form = QFormLayout(pin_grp) + pin_form.setContentsMargins(10, 16, 10, 10) + + self.analog_pins_edit = QLineEdit(", ".join(p[1:] for p in self.device._analog_pins)) + self.analog_pins_edit.setPlaceholderText("e.g. 0, 1, 2, 3 (indices into A0–A5)") + pin_form.addRow("Analog Input Pins:", self.analog_pins_edit) + + self.di_pins_edit = QLineEdit(", ".join(p[1:] for p in self.device._di_pins)) + self.di_pins_edit.setPlaceholderText("e.g. 2, 3, 8") + pin_form.addRow("Digital Input Pins:", self.di_pins_edit) + + self.do_pins_edit = QLineEdit(", ".join(p[1:] for p in self.device._do_pins)) + self.do_pins_edit.setPlaceholderText("e.g. 5, 6, 7, 9") + pin_form.addRow("Digital Output Pins:", self.do_pins_edit) + + hint = QLabel("Changes take effect on Apply & Reconnect.\n" + "Analog: enter A-pin indices (0=A0, 1=A1, …). " + "Digital: enter pin numbers.") + hint.setObjectName("traceSource") + hint.setWordWrap(True) + pin_form.addRow(hint) + + root.addWidget(pin_grp) + + # ── Diagnostics ─────────────────────────────────────────────────── + diag_grp = QGroupBox("Connection Diagnostics") + diag_lay = QVBoxLayout(diag_grp) + diag_lay.setContentsMargins(10, 16, 10, 10) + self._diag_box = QTextEdit() + self._diag_box.setObjectName("codeEditor") + self._diag_box.setReadOnly(True) + self._diag_box.setMaximumHeight(120) + diag_lay.addWidget(self._diag_box) + root.addWidget(diag_grp) + root.addStretch() + self._refresh_diag() + + def _refresh_diag(self): + lines = [ + f"Port: {self.device._port}", + f"Baud: {self.device._baud}", + f"Simulate: {self.device.simulate}", + f"Status: {self.device.status.value}", + f"Analog pins: {', '.join(self.device._analog_pins) or '(none)'}", + f"DI pins: {', '.join(self.device._di_pins) or '(none)'}", + f"DO pins: {', '.join(self.device._do_pins) or '(none)'}", + f"pyserial: {'available' if ArduinoLayer.is_pyserial_available() else 'NOT INSTALLED'}", + ] + if hasattr(self.device._layer, "raw_lines") and self.device._layer.raw_lines: + lines.append("\nLast lines from Arduino:") + for r in self.device._layer.raw_lines: + 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)) + + def _scan_ports(self): + self._scan_btn.setEnabled(False) + self._scan_lbl.setText("Scanning…") + self._port_list.clear() + self._scanner = _PortScanThread() + self._scanner.done.connect(self._on_scan_done) + self._scanner.start() + + def _on_scan_done(self, ports): + self._scan_btn.setEnabled(True) + self._port_list.clear() + if not ports: + self._scan_lbl.setText("No ports found") + item = QListWidgetItem(" No serial ports detected") + item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsSelectable) + self._port_list.addItem(item) + else: + self._scan_lbl.setText(f"{len(ports)} port(s) — click to select") + for device, desc in ports: + label = f" {device}" + if desc and desc.strip() and desc.strip() != device: + label += f" — {desc}" + item = QListWidgetItem(label) + item.setData(Qt.ItemDataRole.UserRole, device) + self._port_list.addItem(item) + + def _on_port_selected(self, item: QListWidgetItem): + port = item.data(Qt.ItemDataRole.UserRole) + if port: + self.port_edit.setText(port) + + def _apply(self): + # Parse pin fields and update device before reconnect so connect() sends correct APIN/DPIN + analog = _parse_analog_pin_edit(self.analog_pins_edit.text()) + di = _parse_pin_edit(self.di_pins_edit.text()) + do = _parse_pin_edit(self.do_pins_edit.text()) + if analog: + self.device._analog_pins = analog + if di: + self.device._di_pins = di + self.device._di_map = {f"di{i}": p for i, p in enumerate(di)} + if do: + self.device._do_pins = do + self.device._do_map = {f"do{i}": p for i, p in enumerate(do)} + + self.device.switch_backend( + simulate=self.sim_chk.isChecked(), + port=self.port_edit.text().strip() or self.device._port, + baud=int(self.baud_cb.currentText()), + ) + self._refresh_diag() diff --git a/devices/digital_io.py b/devices/digital_io.py index 96c6ca5..53395dc 100644 --- a/devices/digital_io.py +++ b/devices/digital_io.py @@ -275,7 +275,6 @@ class DigitalIOConfigWidget(QWidget): def __init__(self, device: DigitalIODevice): super().__init__() self.device = device - self._buttons: Dict[str, QPushButton] = {} self._build() def _build(self): @@ -313,30 +312,6 @@ class DigitalIOConfigWidget(QWidget): self.be_cb.currentTextChanged.connect(self._update_visibility) root.addWidget(be_grp) self._update_visibility(self.device.backend) - - out_grp = QGroupBox("Digital Outputs") - out_lay = QVBoxLayout(out_grp) - for ch in (c for c in self.device.info.channels if c.channel_id.startswith("do")): - row = QHBoxLayout() - lbl = QLabel(ch.name) - lbl.setMinimumWidth(50) - btn = QPushButton("OFF") - btn.setCheckable(True) - btn.setChecked(bool(self.device._output_state.get(ch.channel_id, 0))) - btn.setObjectName("digitalOutBtn") - cid = ch.channel_id - - def _tog(checked, c=cid, b=btn): - self.device.write_channel(c, checked) - b.setText("ON" if checked else "OFF") - - btn.toggled.connect(_tog) - row.addWidget(lbl) - row.addWidget(btn) - out_lay.addLayout(row) - self._buttons[ch.channel_id] = btn - - root.addWidget(out_grp) root.addStretch() def _update_visibility(self, backend: str = ""): diff --git a/devices/nidaqmx_device.py b/devices/nidaqmx_device.py new file mode 100644 index 0000000..88bb932 --- /dev/null +++ b/devices/nidaqmx_device.py @@ -0,0 +1,382 @@ +""" +devices/nidaqmx_device.py + +Combined NI-DAQmx physical device — analog inputs + digital I/O on one NI board. +""" + +from typing import Any, Dict + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QFormLayout, QGroupBox, + QDoubleSpinBox, QCheckBox, QLineEdit, QLabel, QPushButton, + QListWidget, QListWidgetItem, +) +from PyQt6.QtCore import Qt, QThread, pyqtSignal + +from devices.base_device import BaseDevice, ChannelConfig, DeviceInfo, DeviceStatus +from api_layers.nidaqmx_layer import NidaqmxLayer + +_ANALOG_COLORS = [ + "#00d4ff", "#ff6b35", "#7fff6e", "#ffcc00", + "#c77dff", "#ff4d6d", "#4cc9f0", "#f72585", + "#38b000", "#e9c46a", "#a8dadc", "#e63946", + "#90e0ef", "#fb8500", "#b5e48c", "#d62828", +] +_DI_COLORS = ["#4cc9f0", "#90e0ef", "#caf0f8", "#0077b6", "#023e8a", "#48cae4", "#ade8f4", "#00b4d8"] +_DO_COLORS = ["#ff6b35", "#ffcc00", "#f77f00", "#fcbf49", "#d62828", "#e63946", "#fb8500", "#ffd166"] + + +class NidaqmxDevice(BaseDevice): + DEVICE_TYPE = "nidaqmx" + ICON = "🔬" + + def __init__( + self, + device_id: str = "ni_0", + num_analog: int = 4, + min_v: float = -10.0, + max_v: float = 10.0, + num_di: int = 2, + num_do: int = 4, + simulate: bool = True, + ni_device: str = "Dev1", + ): + self.simulate = simulate + self.backend = "nidaqmx" + self._ni_device = ni_device + self._num_analog = num_analog + self._min_v = min_v + self._max_v = max_v + self._num_di = num_di + self._num_do = num_do + self._last_error = "" + + channels = [] + for i in range(num_analog): + channels.append(ChannelConfig( + channel_id=f"ai{i}", name=f"AI{i}", unit="V", + min_value=min_v, max_value=max_v, + color=_ANALOG_COLORS[i % len(_ANALOG_COLORS)], + )) + for i in range(num_di): + channels.append(ChannelConfig( + channel_id=f"di{i}", name=f"DI {i}", unit="", + min_value=0.0, max_value=1.0, + color=_DI_COLORS[i % len(_DI_COLORS)], + )) + for i in range(num_do): + channels.append(ChannelConfig( + channel_id=f"do{i}", name=f"DO {i}", unit="", + min_value=0.0, max_value=1.0, + color=_DO_COLORS[i % len(_DO_COLORS)], + )) + + info = DeviceInfo( + device_id=device_id, + name=f"NI-DAQmx ({ni_device})", + device_type=self.DEVICE_TYPE, + description=f"NI-DAQmx — {num_analog} AI, {num_di} DI, {num_do} DO", + manufacturer="National Instruments", + icon=self.ICON, + channels=channels, + ) + super().__init__(info) + + self._output_state: Dict[str, int] = {f"do{i}": 0 for i in range(num_do)} + self._ai_layer = None + self._ni_in_task = None + self._ni_out_task = None + + # ── BaseDevice ──────────────────────────────────────────────────────────── + + def connect(self) -> bool: + self._last_error = "" + if self.simulate: + self.status = DeviceStatus.SIMULATED + return True + + ok = True + try: + if self._num_analog > 0: + ai_ids = [ch.channel_id for ch in self.info.channels + if ch.channel_id.startswith("ai")] + self._ai_layer = NidaqmxLayer( + device_name=self._ni_device, + channels=ai_ids, + min_val=self._min_v, + max_val=self._max_v, + simulate=False, + ) + ok = ok and self._ai_layer.start() + + if self._num_di > 0 or self._num_do > 0: + ok = ok and self._ni_digital_connect() + except Exception as e: + self._last_error = str(e) + ok = False + + self.status = DeviceStatus.CONNECTED if ok else DeviceStatus.ERROR + return ok + + def _ni_digital_connect(self) -> bool: + try: + import nidaqmx # type: ignore + from nidaqmx.constants import LineGrouping # type: ignore + + if self._num_di > 0: + self._ni_in_task = nidaqmx.Task() + for i in range(self._num_di): + self._ni_in_task.di_channels.add_di_chan( + f"{self._ni_device}/port0/line{i}", + line_grouping=LineGrouping.CHAN_PER_LINE, + ) + self._ni_in_task.start() + + if self._num_do > 0: + self._ni_out_task = nidaqmx.Task() + for i in range(self._num_do): + self._ni_out_task.do_channels.add_do_chan( + f"{self._ni_device}/port1/line{i}", + line_grouping=LineGrouping.CHAN_PER_LINE, + ) + self._ni_out_task.start() + + return True + except Exception as e: + self._last_error = str(e) + return False + + def disconnect(self) -> None: + if self._ai_layer: + try: + self._ai_layer.stop() + except Exception: + pass + self._ai_layer = None + for task in (self._ni_in_task, self._ni_out_task): + if task: + try: + task.stop() + task.close() + except Exception: + pass + self._ni_in_task = None + self._ni_out_task = None + self.status = DeviceStatus.DISCONNECTED + + def read_channels(self) -> Dict[str, float]: + if self.simulate: + return self._sim_read() + + result = {} + if self._ai_layer: + try: + result.update(self._ai_layer.read()) + except Exception: + pass + + try: + if self._ni_in_task: + vals = self._ni_in_task.read() + di_chs = [c for c in self.info.channels if c.channel_id.startswith("di")] + for i, ch in enumerate(di_chs): + result[ch.channel_id] = float(vals[i] if isinstance(vals, list) else vals) + except Exception: + pass + + for ch in (c for c in self.info.channels if c.channel_id.startswith("do")): + result[ch.channel_id] = float(self._output_state.get(ch.channel_id, 0)) + + return result + + def _sim_read(self) -> Dict[str, float]: + import math, time + t = time.time() + result = {} + for i, ch in enumerate([c for c in self.info.channels if c.channel_id.startswith("ai")]): + result[ch.channel_id] = math.sin(t + i) * (self._max_v * 0.5) + for ch in (c for c in self.info.channels if c.channel_id.startswith("di")): + result[ch.channel_id] = 0.0 + for ch in (c for c in self.info.channels if c.channel_id.startswith("do")): + result[ch.channel_id] = float(self._output_state.get(ch.channel_id, 0)) + return result + + def write_channel(self, channel_id: str, value: Any) -> bool: + cid = channel_id.strip() + if cid.startswith("do"): + self._output_state[cid] = int(bool(value)) + if not self.simulate and self._ni_out_task: + try: + do_chs = [c for c in self.info.channels if c.channel_id.startswith("do")] + states = [self._output_state.get(c.channel_id, 0) for c in do_chs] + self._ni_out_task.write(states) + except Exception as e: + print(f"[NidaqmxDevice] write failed: {e}") + return True + return False + + def get_save_config(self) -> dict: + return { + "device_type": self.DEVICE_TYPE, + "device_id": self.info.device_id, + "num_analog": self._num_analog, + "min_v": self._min_v, + "max_v": self._max_v, + "num_di": self._num_di, + "num_do": self._num_do, + "simulate": self.simulate, + "ni_device": self._ni_device, + } + + def get_config_widget(self) -> QWidget: + return NidaqmxConfigWidget(self) + + def switch_backend(self, simulate: bool, ni_device: str, + min_v: float, max_v: float) -> None: + was_running = self.status in (DeviceStatus.CONNECTED, DeviceStatus.SIMULATED) + if was_running: + self.disconnect() + self.simulate = simulate + self._ni_device = ni_device + self._min_v = min_v + self._max_v = max_v + if was_running: + self.connect() + + +# ── NI scanner thread ───────────────────────────────────────────────────────── + +class _NIScanThread(QThread): + done = pyqtSignal(list) + def run(self): + try: + import nidaqmx # type: ignore + devs = [(d.name, d.product_type) for d in nidaqmx.system.System().devices] + except Exception: + devs = [] + self.done.emit(devs) + + +# ── Config widget ───────────────────────────────────────────────────────────── + +class NidaqmxConfigWidget(QWidget): + def __init__(self, device: NidaqmxDevice): + super().__init__() + self.device = device + self._scanner = None + self._build() + + def _build(self): + root = QVBoxLayout(self) + root.setContentsMargins(10, 14, 10, 10) + root.setSpacing(12) + self.setMinimumWidth(420) + + # ── NI settings ─────────────────────────────────────────────────── + ni_grp = QGroupBox("NI-DAQmx Settings") + ni_form = QFormLayout(ni_grp) + ni_form.setContentsMargins(10, 16, 10, 10) + + self.ni_dev_edit = QLineEdit(self.device._ni_device) + ni_form.addRow("NI Device:", self.ni_dev_edit) + + self.min_v_spin = QDoubleSpinBox() + self.min_v_spin.setRange(-100.0, 0.0) + self.min_v_spin.setValue(self.device._min_v) + self.min_v_spin.setSuffix(" V") + ni_form.addRow("Min Voltage:", self.min_v_spin) + + self.max_v_spin = QDoubleSpinBox() + self.max_v_spin.setRange(0.0, 100.0) + self.max_v_spin.setValue(self.device._max_v) + self.max_v_spin.setSuffix(" V") + ni_form.addRow("Max Voltage:", self.max_v_spin) + + self.sim_chk = QCheckBox("Simulation Mode (no hardware)") + self.sim_chk.setChecked(self.device.simulate) + ni_form.addRow(self.sim_chk) + + scan_row = QHBoxLayout() + self._scan_btn = QPushButton("🔍 Scan NI Devices") + self._scan_btn.setObjectName("addTraceBtn") + self._scan_btn.clicked.connect(self._scan_ni) + self._scan_lbl = QLabel("") + self._scan_lbl.setObjectName("traceSource") + scan_row.addWidget(self._scan_btn) + scan_row.addWidget(self._scan_lbl, 1) + ni_form.addRow(scan_row) + + self._ni_list = QListWidget() + self._ni_list.setObjectName("portList") + self._ni_list.setMaximumHeight(80) + self._ni_list.itemClicked.connect(self._on_ni_selected) + ni_form.addRow(self._ni_list) + + apply_btn = QPushButton("Apply & Reconnect") + apply_btn.setObjectName("applyButton") + apply_btn.clicked.connect(self._apply) + ni_form.addRow(apply_btn) + + root.addWidget(ni_grp) + + # ── Diagnostics ─────────────────────────────────────────────────── + diag_grp = QGroupBox("Status") + diag_lay = QVBoxLayout(diag_grp) + diag_lay.setContentsMargins(10, 16, 10, 10) + self._diag_lbl = QLabel() + self._diag_lbl.setObjectName("traceSource") + self._diag_lbl.setWordWrap(True) + diag_lay.addWidget(self._diag_lbl) + root.addWidget(diag_grp) + root.addStretch() + self._refresh_diag() + + def _refresh_diag(self): + lines = [ + f"NI Device: {self.device._ni_device}", + f"Simulate: {self.device.simulate}", + f"Status: {self.device.status.value}", + ] + if self.device._last_error: + lines.append(f"Error: {self.device._last_error}") + self._diag_lbl.setText("\n".join(lines)) + + def _scan_ni(self): + self._scan_btn.setEnabled(False) + self._scan_lbl.setText("Scanning…") + self._ni_list.clear() + self._scanner = _NIScanThread() + self._scanner.done.connect(self._on_ni_found) + self._scanner.start() + + def _on_ni_found(self, devices): + self._scan_btn.setEnabled(True) + self._ni_list.clear() + if not devices: + self._scan_lbl.setText("No NI devices found") + item = QListWidgetItem(" No NI devices detected") + item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsSelectable) + self._ni_list.addItem(item) + else: + self._scan_lbl.setText(f"{len(devices)} found — click to select") + for name, product in devices: + label = f" {name}" + if product: + label += f" — {product}" + item = QListWidgetItem(label) + item.setData(Qt.ItemDataRole.UserRole, name) + self._ni_list.addItem(item) + + def _on_ni_selected(self, item: QListWidgetItem): + name = item.data(Qt.ItemDataRole.UserRole) + if name: + self.ni_dev_edit.setText(name) + + def _apply(self): + self.device.switch_backend( + simulate=self.sim_chk.isChecked(), + ni_device=self.ni_dev_edit.text().strip() or "Dev1", + min_v=self.min_v_spin.value(), + max_v=self.max_v_spin.value(), + ) + self._refresh_diag() diff --git a/ui/__pycache__/__init__.cpython-312.pyc b/ui/__pycache__/__init__.cpython-312.pyc Binary files differindex 42ac581..8dc1c2f 100644 --- a/ui/__pycache__/__init__.cpython-312.pyc +++ 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 differindex ef40b13..15d71d0 100644 --- a/ui/__pycache__/__init__.cpython-314.pyc +++ b/ui/__pycache__/__init__.cpython-314.pyc diff --git a/ui/__pycache__/add_device_dialog.cpython-312.pyc b/ui/__pycache__/add_device_dialog.cpython-312.pyc Binary files differindex 990c229..1e970d6 100644 --- a/ui/__pycache__/add_device_dialog.cpython-312.pyc +++ b/ui/__pycache__/add_device_dialog.cpython-312.pyc diff --git a/ui/__pycache__/add_device_dialog.cpython-314.pyc b/ui/__pycache__/add_device_dialog.cpython-314.pyc Binary files differindex f5852ba..687bc1e 100644 --- a/ui/__pycache__/add_device_dialog.cpython-314.pyc +++ b/ui/__pycache__/add_device_dialog.cpython-314.pyc diff --git a/ui/__pycache__/config_dialog.cpython-312.pyc b/ui/__pycache__/config_dialog.cpython-312.pyc Binary files differindex 5a051dc..91921e5 100644 --- a/ui/__pycache__/config_dialog.cpython-312.pyc +++ b/ui/__pycache__/config_dialog.cpython-312.pyc diff --git a/ui/__pycache__/config_dialog.cpython-314.pyc b/ui/__pycache__/config_dialog.cpython-314.pyc Binary files differnew file mode 100644 index 0000000..91d6b9b --- /dev/null +++ b/ui/__pycache__/config_dialog.cpython-314.pyc diff --git a/ui/__pycache__/control_editor.cpython-312.pyc b/ui/__pycache__/control_editor.cpython-312.pyc Binary files differindex e76411d..3a53377 100644 --- a/ui/__pycache__/control_editor.cpython-312.pyc +++ b/ui/__pycache__/control_editor.cpython-312.pyc diff --git a/ui/__pycache__/control_editor.cpython-314.pyc b/ui/__pycache__/control_editor.cpython-314.pyc Binary files differindex aaea0ee..d3904f7 100644 --- a/ui/__pycache__/control_editor.cpython-314.pyc +++ b/ui/__pycache__/control_editor.cpython-314.pyc diff --git a/ui/__pycache__/control_panel.cpython-312.pyc b/ui/__pycache__/control_panel.cpython-312.pyc Binary files differindex 45f1eb9..1c101a8 100644 --- a/ui/__pycache__/control_panel.cpython-312.pyc +++ 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 differindex 79f9084..a5b859f 100644 --- a/ui/__pycache__/control_panel.cpython-314.pyc +++ b/ui/__pycache__/control_panel.cpython-314.pyc diff --git a/ui/__pycache__/main_window.cpython-312.pyc b/ui/__pycache__/main_window.cpython-312.pyc Binary files differindex 3aefd67..ffd6e12 100644 --- a/ui/__pycache__/main_window.cpython-312.pyc +++ 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 differindex acef085..709972c 100644 --- a/ui/__pycache__/main_window.cpython-314.pyc +++ b/ui/__pycache__/main_window.cpython-314.pyc diff --git a/ui/__pycache__/profile_manager_ui.cpython-312.pyc b/ui/__pycache__/profile_manager_ui.cpython-312.pyc Binary files differindex 93491f7..97cbe3a 100644 --- a/ui/__pycache__/profile_manager_ui.cpython-312.pyc +++ b/ui/__pycache__/profile_manager_ui.cpython-312.pyc diff --git a/ui/__pycache__/strip_chart.cpython-312.pyc b/ui/__pycache__/strip_chart.cpython-312.pyc Binary files differindex a3e25c0..33e713b 100644 --- a/ui/__pycache__/strip_chart.cpython-312.pyc +++ 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 differindex e56015f..ef745d3 100644 --- a/ui/__pycache__/strip_chart.cpython-314.pyc +++ b/ui/__pycache__/strip_chart.cpython-314.pyc diff --git a/ui/add_device_dialog.py b/ui/add_device_dialog.py index 4df71a3..5258995 100644 --- a/ui/add_device_dialog.py +++ b/ui/add_device_dialog.py @@ -1,12 +1,9 @@ """ ui/add_device_dialog.py -Add Device dialog — type-aware, shows the right fields for each device. - -Each device type gets its own config panel so the user sees exactly -the fields they need and nothing irrelevant. - -Serial port scanning populates a clickable list that sets the port field. +Add Device dialog — user selects physical device type (Arduino / NI-DAQ / Serial). +Backend is determined by device type; Arduino and NI-DAQ combine analog input +and digital I/O into one physical device entry. """ import os @@ -21,26 +18,22 @@ from PyQt6.QtCore import Qt, QThread, pyqtSignal from PyQt6.QtGui import QFont from devices.device_registry import DeviceRegistry -from devices.analog_input import AnalogInputDevice -from devices.digital_io import DigitalIODevice +from devices.arduino_device import ArduinoDevice +from devices.nidaqmx_device import NidaqmxDevice from devices.serial_device import SerialDevice, _FORMAT_LABELS -# ── Port scanner thread ─────────────────────────────────────────────────────── +# ── Background scan threads ─────────────────────────────────────────────────── class PortScanThread(QThread): ports_found = pyqtSignal(list) - def run(self): from api_layers.arduino_layer import ArduinoLayer self.ports_found.emit(ArduinoLayer.list_ports()) -# ── Background scan threads ─────────────────────────────────────────────────── - class NIScanThread(QThread): devices_found = pyqtSignal(list) - def run(self): try: import nidaqmx # type: ignore @@ -50,14 +43,14 @@ class NIScanThread(QThread): self.devices_found.emit(devs) -# ── Reusable port scanner widget ───────────────────────────────────────────── +# ── Reusable scanner widgets ────────────────────────────────────────────────── class PortScanGroup(QGroupBox): """Scan-and-click widget that fills a target QLineEdit with the chosen port.""" def __init__(self, target_edit: QLineEdit): super().__init__("Available Serial Ports") - self._target = target_edit + self._target = target_edit self._scanner = None lay = QVBoxLayout(self) @@ -121,7 +114,7 @@ class NIScanGroup(QGroupBox): def __init__(self, target_edit: QLineEdit): super().__init__("Available NI Devices") - self._target = target_edit + self._target = target_edit self._scanner = None lay = QVBoxLayout(self) @@ -182,159 +175,111 @@ class NIScanGroup(QGroupBox): # ── Per-type config panels ──────────────────────────────────────────────────── -class AnalogInputPanel(QWidget): - """Config fields for AnalogInputDevice.""" +class ArduinoPanel(QWidget): + """Config fields for ArduinoDevice (analog + digital I/O combined).""" def __init__(self): super().__init__() lay = QFormLayout(self) lay.setContentsMargins(0, 4, 0, 4) - # Backend - self.backend_cb = QComboBox() - self.backend_cb.addItems(["nidaqmx", "arduino"]) - lay.addRow("Backend:", self.backend_cb) - - # Channel count - self.ch_spin = QSpinBox() - self.ch_spin.setRange(1, 16) - self.ch_spin.setValue(4) - lay.addRow("Channels:", self.ch_spin) - - # Voltage range (NI) - self.ni_device_edit = QLineEdit("Dev1") - lay.addRow("NI Device:", self.ni_device_edit) + self.port_edit = QLineEdit("") + self.port_edit.setPlaceholderText("e.g. COM3 or /dev/ttyUSB0") + lay.addRow("Port:", self.port_edit) - self.min_v_spin = QDoubleSpinBox() - self.min_v_spin.setRange(-100.0, 0.0) - self.min_v_spin.setValue(-10.0) - self.min_v_spin.setSuffix(" V") - lay.addRow("Min Voltage:", self.min_v_spin) + self.baud_cb = QComboBox() + self.baud_cb.addItems(["9600", "57600", "115200", "230400"]) + self.baud_cb.setCurrentText("115200") + lay.addRow("Baud Rate:", self.baud_cb) - self.max_v_spin = QDoubleSpinBox() - self.max_v_spin.setRange(0.0, 100.0) - self.max_v_spin.setValue(10.0) - self.max_v_spin.setSuffix(" V") - lay.addRow("Max Voltage:", self.max_v_spin) + self.analog_pins_edit = QLineEdit("0, 1, 2, 3") + self.analog_pins_edit.setPlaceholderText("e.g. 0, 1, 2, 3 (indices into A0–A5)") + lay.addRow("Analog Input Pins:", self.analog_pins_edit) - self.ard_port_edit = QLineEdit("") - self.ard_port_edit.setPlaceholderText("e.g. COM3 or /dev/ttyUSB0") - lay.addRow("Arduino Port:", self.ard_port_edit) + self.di_pins_edit = QLineEdit("2, 3") + self.di_pins_edit.setPlaceholderText("e.g. 2, 3, 8") + lay.addRow("Digital Input Pins:", self.di_pins_edit) - self.ard_baud_cb = QComboBox() - self.ard_baud_cb.addItems(["9600", "57600", "115200", "230400"]) - self.ard_baud_cb.setCurrentText("115200") - lay.addRow("Arduino Baud:", self.ard_baud_cb) + self.do_pins_edit = QLineEdit("5, 6, 7, 9") + self.do_pins_edit.setPlaceholderText("e.g. 5, 6, 7, 9") + lay.addRow("Digital Output Pins:", self.do_pins_edit) - # Simulate self.sim_chk = QCheckBox("Simulation mode") self.sim_chk.setChecked(False) lay.addRow(self.sim_chk) - self._ni_scan_grp = NIScanGroup(self.ni_device_edit) - lay.addRow(self._ni_scan_grp) - - self._ard_scan_grp = PortScanGroup(self.ard_port_edit) - lay.addRow(self._ard_scan_grp) - - self._ni_widgets = [self.ni_device_edit, self.min_v_spin, self.max_v_spin, self._ni_scan_grp] - self._ard_widgets = [self.ard_port_edit, self.ard_baud_cb, self._ard_scan_grp] - self.backend_cb.currentTextChanged.connect(self._on_backend_changed) - self._on_backend_changed(self.backend_cb.currentText()) - - def _on_backend_changed(self, backend: str): - ni = backend == "nidaqmx" - lay = self.layout() - for w in self._ni_widgets: - w.setVisible(ni) - lbl = lay.labelForField(w) - if lbl: - lbl.setVisible(ni) - for w in self._ard_widgets: - w.setVisible(not ni) - lbl = lay.labelForField(w) - if lbl: - lbl.setVisible(not ni) - - def build_device(self, device_id: str) -> AnalogInputDevice: - return AnalogInputDevice( + lay.addRow(PortScanGroup(self.port_edit)) + + def build_device(self, device_id: str) -> ArduinoDevice: + from devices.arduino_device import _parse_pin_edit, _parse_analog_pin_edit + analog = _parse_analog_pin_edit(self.analog_pins_edit.text()) or None + di = _parse_pin_edit(self.di_pins_edit.text()) or None + do = _parse_pin_edit(self.do_pins_edit.text()) or None + return ArduinoDevice( device_id=device_id, - num_channels=self.ch_spin.value(), + analog_pins=analog, + di_pins=di, + do_pins=do, simulate=self.sim_chk.isChecked(), - backend=self.backend_cb.currentText(), - ni_device=self.ni_device_edit.text().strip() or "Dev1", - ni_min_v=self.min_v_spin.value(), - ni_max_v=self.max_v_spin.value(), - ard_port=self.ard_port_edit.text().strip() or "COM3", - ard_baud=int(self.ard_baud_cb.currentText()), + port=self.port_edit.text().strip() or "COM3", + baud=int(self.baud_cb.currentText()), ) -class DigitalIOPanel(QWidget): - """Config fields for DigitalIODevice.""" +class NidaqmxPanel(QWidget): + """Config fields for NidaqmxDevice (analog + digital I/O combined).""" def __init__(self): super().__init__() lay = QFormLayout(self) lay.setContentsMargins(0, 4, 0, 4) - self.backend_cb = QComboBox() - self.backend_cb.addItems(["nidaqmx", "arduino"]) - lay.addRow("Backend:", self.backend_cb) + self.ni_device_edit = QLineEdit("Dev1") + lay.addRow("NI Device:", self.ni_device_edit) + + self.analog_spin = QSpinBox() + self.analog_spin.setRange(0, 16) + self.analog_spin.setValue(4) + lay.addRow("Analog Inputs:", self.analog_spin) - self.in_spin = QSpinBox() - self.in_spin.setRange(0, 32) - self.in_spin.setValue(4) - lay.addRow("Digital Inputs:", self.in_spin) + self.min_v_spin = QDoubleSpinBox() + self.min_v_spin.setRange(-100.0, 0.0) + self.min_v_spin.setValue(-10.0) + self.min_v_spin.setSuffix(" V") + lay.addRow("Min Voltage:", self.min_v_spin) - self.out_spin = QSpinBox() - self.out_spin.setRange(0, 32) - self.out_spin.setValue(4) - lay.addRow("Digital Outputs:", self.out_spin) + self.max_v_spin = QDoubleSpinBox() + self.max_v_spin.setRange(0.0, 100.0) + self.max_v_spin.setValue(10.0) + self.max_v_spin.setSuffix(" V") + lay.addRow("Max Voltage:", self.max_v_spin) - self.ni_device_edit = QLineEdit("Dev1") - lay.addRow("NI Device:", self.ni_device_edit) + self.di_spin = QSpinBox() + self.di_spin.setRange(0, 32) + self.di_spin.setValue(2) + lay.addRow("Digital Inputs:", self.di_spin) - self.ard_port_edit = QLineEdit("") - self.ard_port_edit.setPlaceholderText("e.g. COM3 or /dev/ttyUSB0") - lay.addRow("Arduino Port:", self.ard_port_edit) + self.do_spin = QSpinBox() + self.do_spin.setRange(0, 32) + self.do_spin.setValue(4) + lay.addRow("Digital Outputs:", self.do_spin) self.sim_chk = QCheckBox("Simulation mode") self.sim_chk.setChecked(False) lay.addRow(self.sim_chk) - self._ni_scan_grp = NIScanGroup(self.ni_device_edit) - lay.addRow(self._ni_scan_grp) - - self._ard_scan_grp = PortScanGroup(self.ard_port_edit) - lay.addRow(self._ard_scan_grp) - - self.backend_cb.currentTextChanged.connect(self._on_backend_changed) - self._on_backend_changed(self.backend_cb.currentText()) - - def _on_backend_changed(self, backend: str): - ni = backend == "nidaqmx" - lay = self.layout() - for w, show in [ - (self.ni_device_edit, ni), - (self._ni_scan_grp, ni), - (self.ard_port_edit, not ni), - (self._ard_scan_grp, not ni), - ]: - w.setVisible(show) - lbl = lay.labelForField(w) - if lbl: - lbl.setVisible(show) - - def build_device(self, device_id: str) -> DigitalIODevice: - return DigitalIODevice( + lay.addRow(NIScanGroup(self.ni_device_edit)) + + def build_device(self, device_id: str) -> NidaqmxDevice: + return NidaqmxDevice( device_id=device_id, - num_inputs=self.in_spin.value(), - num_outputs=self.out_spin.value(), + num_analog=self.analog_spin.value(), + min_v=self.min_v_spin.value(), + max_v=self.max_v_spin.value(), + num_di=self.di_spin.value(), + num_do=self.do_spin.value(), simulate=self.sim_chk.isChecked(), - backend=self.backend_cb.currentText(), ni_device=self.ni_device_edit.text().strip() or "Dev1", - ard_port=self.ard_port_edit.text().strip() or "COM3", ) @@ -367,7 +312,7 @@ class SerialPanel(QWidget): self.sim_chk.setChecked(False) form.addRow(self.sim_chk) - note = QLabel("Protocol-specific settings (queries, registers, motors) available in device config dialog.") + note = QLabel("Protocol-specific settings available in device config dialog.") note.setObjectName("traceSource") note.setWordWrap(True) @@ -389,9 +334,9 @@ class SerialPanel(QWidget): # ── Main dialog ─────────────────────────────────────────────────────────────── _PANELS = { - "Analog Input": AnalogInputPanel, - "Digital I/O": DigitalIOPanel, - "Serial / UART": SerialPanel, + "Arduino": (ArduinoPanel, "ard"), + "NI-DAQ": (NidaqmxPanel, "ni"), + "Serial / UART":(SerialPanel, "ser"), } @@ -401,23 +346,23 @@ class AddDeviceDialog(QDialog): self.registry = registry self.created_device = None self.setWindowTitle("Add Device") - self.setMinimumSize(480, 460) - self.resize(500, 600) + self.setMinimumSize(480, 480) + self.resize(500, 620) self._build() def _build(self): root = QVBoxLayout(self) root.setSpacing(10) - # Header hdr = QLabel("Add New Device") hdr.setObjectName("devWindowTitle") root.addWidget(hdr) - div = QFrame(); div.setFrameShape(QFrame.Shape.HLine) - div.setObjectName("devWindowDivider"); root.addWidget(div) + div = QFrame() + div.setFrameShape(QFrame.Shape.HLine) + div.setObjectName("devWindowDivider") + root.addWidget(div) - # Device type selector type_row = QFormLayout() self._type_cb = QComboBox() self._type_cb.addItems(list(_PANELS.keys())) @@ -425,26 +370,28 @@ class AddDeviceDialog(QDialog): type_row.addRow("Device Type:", self._type_cb) self._id_edit = QLineEdit() - self._id_edit.setPlaceholderText("Leave blank for auto (e.g. ai_1, dio_0)") + self._id_edit.setPlaceholderText("Leave blank for auto") type_row.addRow("Device ID:", self._id_edit) root.addLayout(type_row) - div2 = QFrame(); div2.setFrameShape(QFrame.Shape.HLine) - div2.setObjectName("devWindowDivider"); root.addWidget(div2) + div2 = QFrame() + div2.setFrameShape(QFrame.Shape.HLine) + div2.setObjectName("devWindowDivider") + root.addWidget(div2) - # Stacked type-specific panels self._stack = QStackedWidget() self._panels = {} - for name, cls in _PANELS.items(): + for name, (cls, _prefix) in _PANELS.items(): panel = cls() self._panels[name] = panel self._stack.addWidget(panel) root.addWidget(self._stack, 1) - div3 = QFrame(); div3.setFrameShape(QFrame.Shape.HLine) - div3.setObjectName("devWindowDivider"); root.addWidget(div3) + div3 = QFrame() + div3.setFrameShape(QFrame.Shape.HLine) + div3.setObjectName("devWindowDivider") + root.addWidget(div3) - # Buttons btn_row = QHBoxLayout() btn_row.addStretch() cancel = QPushButton("Cancel") @@ -457,16 +404,12 @@ class AddDeviceDialog(QDialog): btn_row.addWidget(add) root.addLayout(btn_row) + self._on_type_changed(0) + def _on_type_changed(self, idx: int): self._stack.setCurrentIndex(idx) - # Auto-suggest a device ID based on type type_name = self._type_cb.currentText() - prefixes = { - "Analog Input": "ai", - "Digital I/O": "dio", - "Serial / UART": "ser", - } - prefix = prefixes.get(type_name, "dev") + _, prefix = _PANELS.get(type_name, (None, "dev")) existing = {d.info.device_id for d in self.registry.all_instances()} for i in range(100): candidate = f"{prefix}_{i}" @@ -479,14 +422,8 @@ class AddDeviceDialog(QDialog): panel = self._panels[type_name] dev_id = self._id_edit.text().strip() - # Auto-generate ID if blank if not dev_id: - prefixes = { - "Analog Input": "ai", - "Digital I/O": "dio", - "Serial / UART": "ser", - } - prefix = prefixes.get(type_name, "dev") + _, prefix = _PANELS.get(type_name, (None, "dev")) existing = {d.info.device_id for d in self.registry.all_instances()} for i in range(100): candidate = f"{prefix}_{i}" diff --git a/ui/config_dialog.py b/ui/config_dialog.py index 4976b36..bafac24 100644 --- a/ui/config_dialog.py +++ b/ui/config_dialog.py @@ -4,8 +4,8 @@ ui/config_dialog.py — Device configuration dialog (tabbed). from PyQt6.QtWidgets import ( QDialog, QVBoxLayout, QHBoxLayout, QTabWidget, - QWidget, QFormLayout, QGroupBox, QScrollArea, - QLabel, QLineEdit, QDoubleSpinBox, QCheckBox, + QWidget, QFormLayout, QScrollArea, + QLabel, QLineEdit, QPushButton, ) from PyQt6.QtCore import Qt @@ -37,10 +37,7 @@ class DeviceConfigDialog(QDialog): scroll.setWidget(cfg_widget) tabs.addTab(scroll, "Hardware / Backend") - # ── Tab 2: Channel settings ─────────────────────────────────── - tabs.addTab(self._channel_tab(), "Channels") - - # ── Tab 3: Device info ──────────────────────────────────────── + # ── Tab 2: Device info ──────────────────────────────────────── tabs.addTab(self._info_tab(), "Info") layout.addWidget(tabs) @@ -53,43 +50,6 @@ class DeviceConfigDialog(QDialog): btn_row.addWidget(close_btn) layout.addLayout(btn_row) - def _channel_tab(self): - w = QScrollArea() - w.setWidgetResizable(True) - container = QWidget() - layout = QVBoxLayout(container) - - for ch in self.device.info.channels: - grp = QGroupBox(f"{ch.channel_id} — {ch.name}") - form = QFormLayout(grp) - - name_e = QLineEdit(ch.name) - unit_e = QLineEdit(ch.unit) - en_chk = QCheckBox() - en_chk.setChecked(ch.enabled) - - form.addRow("Name:", name_e) - form.addRow("Unit:", unit_e) - form.addRow("Enabled:", en_chk) - - apply = QPushButton("Apply") - apply.setObjectName("applyButton") - - def _make_apply(c, ne, ue, ec): - def _do(): - c.name = ne.text() - c.unit = ue.text() - c.enabled = ec.isChecked() - return _do - - apply.clicked.connect(_make_apply(ch, name_e, unit_e, en_chk)) - form.addRow(apply) - layout.addWidget(grp) - - layout.addStretch() - w.setWidget(container) - return w - def _info_tab(self): w = QWidget() form = QFormLayout(w) diff --git a/ui/main_window.py b/ui/main_window.py index 483ca49..0bb62f6 100644 --- a/ui/main_window.py +++ b/ui/main_window.py @@ -27,8 +27,8 @@ class _WheelBlocker(QObject): return True return super().eventFilter(obj, event) -from devices.analog_input import AnalogInputDevice -from devices.digital_io import DigitalIODevice +from devices.arduino_device import ArduinoDevice +from devices.nidaqmx_device import NidaqmxDevice from devices.serial_device import SerialDevice from devices.device_registry import DeviceRegistry from core.acquisition import AcquisitionEngine @@ -75,15 +75,10 @@ class MainWindow(QMainWindow): # ── Demo ────────────────────────────────────────────────────────────── def _init_demo_devices(self): - # NOTE: if you have a real Arduino, configure ard_0 and dio_0 to use - # the same port — port_registry ensures only one serial connection - # is opened, and both devices share it automatically. for dev in [ - AnalogInputDevice(device_id="ai_0", num_channels=4, simulate=True, backend="nidaqmx"), - AnalogInputDevice(device_id="ard_0", num_channels=1, simulate=True, backend="arduino"), - DigitalIODevice (device_id="dio_0", num_inputs=2, num_outputs=4, - simulate=True, backend="arduino"), - SerialDevice (device_id="ser_0", num_channels=3, simulate=True), + NidaqmxDevice(device_id="ni_0", num_analog=4, num_di=2, num_do=4, simulate=True), + ArduinoDevice(device_id="ard_0", num_analog=4, num_di=2, num_do=4, simulate=True), + SerialDevice (device_id="ser_0", num_channels=3, simulate=True), ]: dev.connect() self.registry.add_instance(dev) @@ -217,6 +212,7 @@ class MainWindow(QMainWindow): self._win_devices.device_added.connect(self._on_device_added) self._win_devices.device_removed.connect(self._on_device_removed) self._win_devices.device_reconfigured.connect(self._on_device_reconfigured) + self._win_devices.channel_visibility_changed.connect(self._on_channel_visibility_changed) self._win_devices.closed.connect(lambda: self._btn_devices.setChecked(False)) self._show_win(self._win_devices, "right") @@ -225,6 +221,7 @@ class MainWindow(QMainWindow): self._win_signals = SignalsWindow(self.registry, self.processor, self) self._win_signals.derived_changed.connect(self._on_derived_changed) self._win_signals.visibility_changed.connect(self._chart.refresh) + self._win_signals.channel_toggled.connect(self._on_channel_visibility_changed) self._win_signals.closed.connect(lambda: self._btn_signals.setChecked(False)) self._show_win(self._win_signals, "right") @@ -341,6 +338,13 @@ class MainWindow(QMainWindow): if self._win_plot: self._win_plot.refresh_channels() self._status.setText(f"Device '{dev_id}' reconfigured.") + def _on_channel_visibility_changed(self, dev_id: str, ch_id: str, enabled: bool): + self._chart.on_channel_enabled_changed(dev_id, ch_id, enabled) + if self._win_signals: + self._win_signals.on_channel_enabled_changed(dev_id, ch_id, enabled) + if self._win_plot: + self._win_plot.refresh_channels() + def _on_derived_changed(self): self._chart.refresh() if self._win_plot: self._win_plot.refresh_channels() diff --git a/ui/strip_chart.py b/ui/strip_chart.py index 7bc2a18..523102d 100644 --- a/ui/strip_chart.py +++ b/ui/strip_chart.py @@ -158,6 +158,11 @@ class StripChartWidget(QWidget): def refresh(self): self.apply_layout(self._cfg or build_default_layout(self.registry, self.processor)) + def on_channel_enabled_changed(self, dev_id: str, ch_id: str, enabled: bool): + key = (dev_id, ch_id) + for e in self._curves.get(key, []): + e["curve"].setVisible(enabled) + @pyqtSlot(str, str, float, float) def on_new_data(self, device_id: str, channel_id: str, ts: float, val: float): if self._paused or not _HAS_PG or not self._cfg: return diff --git a/ui/windows/__pycache__/__init__.cpython-312.pyc b/ui/windows/__pycache__/__init__.cpython-312.pyc Binary files differindex 1454fd8..66495c5 100644 --- a/ui/windows/__pycache__/__init__.cpython-312.pyc +++ b/ui/windows/__pycache__/__init__.cpython-312.pyc diff --git a/ui/windows/__pycache__/devices_window.cpython-312.pyc b/ui/windows/__pycache__/devices_window.cpython-312.pyc Binary files differindex fb52b79..dc62b48 100644 --- a/ui/windows/__pycache__/devices_window.cpython-312.pyc +++ 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 differindex e1a4868..9f819dd 100644 --- a/ui/windows/__pycache__/devices_window.cpython-314.pyc +++ b/ui/windows/__pycache__/devices_window.cpython-314.pyc diff --git a/ui/windows/__pycache__/plot_window.cpython-312.pyc b/ui/windows/__pycache__/plot_window.cpython-312.pyc Binary files differindex 7499763..d3a1583 100644 --- a/ui/windows/__pycache__/plot_window.cpython-312.pyc +++ 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 differindex 30b0a23..9782293 100644 --- a/ui/windows/__pycache__/plot_window.cpython-314.pyc +++ b/ui/windows/__pycache__/plot_window.cpython-314.pyc diff --git a/ui/windows/__pycache__/settings_window.cpython-312.pyc b/ui/windows/__pycache__/settings_window.cpython-312.pyc Binary files differindex 47d5611..d596487 100644 --- a/ui/windows/__pycache__/settings_window.cpython-312.pyc +++ 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 differindex cc9691b..f732521 100644 --- a/ui/windows/__pycache__/settings_window.cpython-314.pyc +++ b/ui/windows/__pycache__/settings_window.cpython-314.pyc diff --git a/ui/windows/__pycache__/signals_window.cpython-312.pyc b/ui/windows/__pycache__/signals_window.cpython-312.pyc Binary files differindex ec57533..9872798 100644 --- a/ui/windows/__pycache__/signals_window.cpython-312.pyc +++ 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 differindex 224e31e..2027f59 100644 --- a/ui/windows/__pycache__/signals_window.cpython-314.pyc +++ b/ui/windows/__pycache__/signals_window.cpython-314.pyc diff --git a/ui/windows/devices_window.py b/ui/windows/devices_window.py index fbaf9fa..8f6a48b 100644 --- a/ui/windows/devices_window.py +++ b/ui/windows/devices_window.py @@ -118,6 +118,17 @@ class DeviceRow(QFrame): class ChannelsTab(QWidget): """Shows all channels across all devices in an editable table.""" + channel_visibility_changed = pyqtSignal(str, str, bool) # dev_id, ch_id, enabled + + # Col indices + _C_DEVICE = 0 + _C_CH_ID = 1 + _C_ENABLED = 2 + _C_NAME = 3 + _C_UNIT = 4 + _C_MIN = 5 + _C_MAX = 6 + def __init__(self, registry: DeviceRegistry): super().__init__() self.registry = registry @@ -129,18 +140,23 @@ class ChannelsTab(QWidget): self._table = QTableWidget() self._table.setObjectName("channelTable") - self._table.setColumnCount(6) + self._table.setColumnCount(7) self._table.setHorizontalHeaderLabels( - ["Device", "Channel ID", "Name", "Unit", "Min", "Max"] + ["Device", "Channel ID", "On", "Name", "Unit", "Min", "Max"] ) - self._table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch) - self._table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) + hdr = self._table.horizontalHeader() + hdr.setSectionResizeMode(self._C_NAME, QHeaderView.ResizeMode.Stretch) + hdr.setSectionResizeMode(self._C_DEVICE, QHeaderView.ResizeMode.ResizeToContents) + hdr.setSectionResizeMode(self._C_CH_ID, QHeaderView.ResizeMode.ResizeToContents) + hdr.setSectionResizeMode(self._C_ENABLED, QHeaderView.ResizeMode.ResizeToContents) self._table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) self._table.setAlternatingRowColors(True) self._table.verticalHeader().setVisible(False) + self._table.cellChanged.connect(self._on_cell_changed) lay.addWidget(self._table) def refresh(self): + self._table.blockSignals(True) self._table.setRowCount(0) row = 0 for dev in self.registry.all_instances(): @@ -150,35 +166,57 @@ class ChannelsTab(QWidget): dev_item = QTableWidgetItem(f"{dev.info.icon} {dev.info.device_id}") dev_item.setFlags(dev_item.flags() & ~Qt.ItemFlag.ItemIsEditable) dev_item.setForeground(QColor("#64748b")) - self._table.setItem(row, 0, dev_item) + self._table.setItem(row, self._C_DEVICE, dev_item) ch_item = QTableWidgetItem(ch.channel_id) ch_item.setFlags(ch_item.flags() & ~Qt.ItemFlag.ItemIsEditable) ch_item.setForeground(QColor(ch.color)) - self._table.setItem(row, 1, ch_item) + self._table.setItem(row, self._C_CH_ID, ch_item) + + # Enabled checkbox — centred in cell + chk_container = QWidget() + chk_lay = QHBoxLayout(chk_container) + chk_lay.setContentsMargins(0, 0, 0, 0) + chk_lay.setAlignment(Qt.AlignmentFlag.AlignCenter) + chk = QCheckBox() + chk.setChecked(ch.enabled) + dev_id_cap = dev.info.device_id + ch_id_cap = ch.channel_id + chk.toggled.connect( + lambda checked, d=dev_id_cap, c=ch_id_cap: self._on_enabled(d, c, checked) + ) + chk_lay.addWidget(chk) + self._table.setCellWidget(row, self._C_ENABLED, chk_container) name_item = QTableWidgetItem(ch.name) - self._table.setItem(row, 2, name_item) + name_item.setData(Qt.ItemDataRole.UserRole, (dev.info.device_id, ch.channel_id)) + self._table.setItem(row, self._C_NAME, name_item) unit_item = QTableWidgetItem(ch.unit) - self._table.setItem(row, 3, unit_item) + self._table.setItem(row, self._C_UNIT, unit_item) min_item = QTableWidgetItem(str(ch.min_value)) max_item = QTableWidgetItem(str(ch.max_value)) - self._table.setItem(row, 4, min_item) - self._table.setItem(row, 5, max_item) + self._table.setItem(row, self._C_MIN, min_item) + self._table.setItem(row, self._C_MAX, max_item) - # Store references for saving - name_item.setData(Qt.ItemDataRole.UserRole, (dev.info.device_id, ch.channel_id)) row += 1 - self._table.cellChanged.connect(self._on_cell_changed) + self._table.blockSignals(False) + + def _on_enabled(self, dev_id: str, ch_id: str, checked: bool): + dev = self.registry.get_instance(dev_id) + if dev: + ch = dev.get_channel(ch_id) + if ch: + ch.enabled = checked + self.channel_visibility_changed.emit(dev_id, ch_id, checked) def _on_cell_changed(self, row: int, col: int): - item = self._table.item(row, 2) # name col - if item is None: + name_item = self._table.item(row, self._C_NAME) + if name_item is None: return - ref = item.data(Qt.ItemDataRole.UserRole) + ref = name_item.data(Qt.ItemDataRole.UserRole) if ref is None: return dev_id, ch_id = ref @@ -188,27 +226,28 @@ class ChannelsTab(QWidget): ch = dev.get_channel(ch_id) if not ch: return - if col == 2: - ch.name = self._table.item(row, 2).text() - elif col == 3: - ch.unit = self._table.item(row, 3).text() - elif col == 4: + if col == self._C_NAME: + ch.name = self._table.item(row, self._C_NAME).text() + elif col == self._C_UNIT: + ch.unit = self._table.item(row, self._C_UNIT).text() + elif col == self._C_MIN: try: - ch.min_value = float(self._table.item(row, 4).text()) + ch.min_value = float(self._table.item(row, self._C_MIN).text()) except ValueError: pass - elif col == 5: + elif col == self._C_MAX: try: - ch.max_value = float(self._table.item(row, 5).text()) + ch.max_value = float(self._table.item(row, self._C_MAX).text()) except ValueError: pass class DevicesWindow(QWidget): - device_added = pyqtSignal() - device_removed = pyqtSignal(str) - device_reconfigured = pyqtSignal(str) - closed = pyqtSignal() + device_added = pyqtSignal() + device_removed = pyqtSignal(str) + device_reconfigured = pyqtSignal(str) + channel_visibility_changed = pyqtSignal(str, str, bool) # dev_id, ch_id, enabled + closed = pyqtSignal() def __init__(self, registry: DeviceRegistry, engine: AcquisitionEngine, parent=None): super().__init__(parent, Qt.WindowType.Window | Qt.WindowType.Tool) @@ -265,6 +304,7 @@ class DevicesWindow(QWidget): # Tab 2: Channels self._ch_tab = ChannelsTab(self.registry) + self._ch_tab.channel_visibility_changed.connect(self.channel_visibility_changed) tabs.addTab(self._ch_tab, " Channels ") tabs.currentChanged.connect(lambda i: self._ch_tab.refresh() if i == 1 else None) diff --git a/ui/windows/plot_window.py b/ui/windows/plot_window.py index ea1ba1c..9a13781 100644 --- a/ui/windows/plot_window.py +++ b/ui/windows/plot_window.py @@ -207,6 +207,8 @@ class PaneBlock(QFrame): self._x_cb.addItem("⏱ Time (elapsed s)", userData="time") for dev in self.registry.all_instances(): for ch in dev.info.channels: + if not ch.enabled: + continue self._x_cb.addItem(f"{dev.info.device_id}/{ch.channel_id} ({ch.name})", userData=f"{dev.info.device_id}/{ch.channel_id}") for dc in self.processor.get_derived(): @@ -271,6 +273,8 @@ class PaneBlock(QFrame): cb = QComboBox(); cb.setObjectName("channelPickerCb") for dev in self.registry.all_instances(): for ch in dev.info.channels: + if not ch.enabled: + continue cb.addItem(f"{dev.info.device_id} / {ch.channel_id} ({ch.name})", userData=(dev.info.device_id, ch.channel_id, ch.name, ch.color)) for dc in self.processor.get_derived(): diff --git a/ui/windows/settings_window.py b/ui/windows/settings_window.py index 88093ff..9d7b7bc 100644 --- a/ui/windows/settings_window.py +++ b/ui/windows/settings_window.py @@ -7,14 +7,13 @@ Tabs: General — theme (dark/light/system), font sizes, polling rate Acquisition — sample rate, buffer size, CSV log directory Display — default time window, legend, grid defaults - Controls — configure output widget assignments per channel """ from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, - QTabWidget, QFrame, QFormLayout, QGroupBox, QComboBox, + QTabWidget, QFrame, QFormLayout, QComboBox, QSpinBox, QDoubleSpinBox, QCheckBox, QLineEdit, - QFileDialog, QScrollArea, QSizePolicy, + QFileDialog, QScrollArea, ) from PyQt6.QtCore import Qt, pyqtSignal from PyQt6.QtGui import QCloseEvent, QFont @@ -73,7 +72,6 @@ class SettingsWindow(QWidget): tabs.addTab(self._general_tab(), " General ") tabs.addTab(self._acquisition_tab(), " Acquisition ") tabs.addTab(self._display_tab(), " Display ") - tabs.addTab(self._controls_tab(), " Controls ") # Bottom bar btm = QWidget(); btm.setObjectName("cfgBottomBar") @@ -159,44 +157,6 @@ class SettingsWindow(QWidget): root = QVBoxLayout(w); root.setContentsMargins(0,0,0,0); root.addWidget(scroll) return w - def _controls_tab(self): - """Output channel assignments — which device channel each control widget drives.""" - w = QWidget() - scroll = QScrollArea(); scroll.setWidgetResizable(True) - scroll.setObjectName("deviceScroll") - cont = QWidget(); lay = QVBoxLayout(cont) - lay.setContentsMargins(14,12,14,12); lay.setSpacing(8) - - info = QLabel( - "Configure which physical output channels are driven by each control widget.\n" - "Add output widget mappings below. Changes take effect on next app start." - ) - info.setObjectName("traceSource"); info.setWordWrap(True) - lay.addWidget(info) - - grp = QGroupBox("Output Assignments") - g_lay = QFormLayout(grp); g_lay.setSpacing(6) - - # Collect digital output channels - out_channels = ["— none —"] - for dev in self.registry.all_instances(): - for ch in dev.info.channels: - if ch.channel_id.startswith("do") or "out" in ch.channel_id.lower(): - out_channels.append(f"{dev.info.device_id} / {ch.channel_id} ({ch.name})") - - self._out_combos = {} - for label in ["Pump Power", "Heater", "Motor Enable", "PWM Ch 1"]: - cb = QComboBox(); cb.setObjectName("channelPickerCb") - cb.addItems(out_channels) - g_lay.addRow(f"{label}:", cb) - self._out_combos[label] = cb - - lay.addWidget(grp) - lay.addStretch() - scroll.setWidget(cont) - root = QVBoxLayout(w); root.setContentsMargins(0,0,0,0); root.addWidget(scroll) - return w - # ── Actions ─────────────────────────────────────────────────────────── def _browse_log(self): diff --git a/ui/windows/signals_window.py b/ui/windows/signals_window.py index ff863af..0bf9dba 100644 --- a/ui/windows/signals_window.py +++ b/ui/windows/signals_window.py @@ -40,6 +40,8 @@ def _channel_combo(registry: DeviceRegistry, cb = QComboBox(); cb.setObjectName("channelPickerCb") for dev in registry.all_instances(): for ch in dev.info.channels: + if not ch.enabled: + continue label = f"{dev.info.device_id} / {ch.channel_id} ({ch.name})" if show_unit and ch.unit: label += f" [{ch.unit}]" @@ -255,7 +257,8 @@ class PipelineTab(QWidget): super().__init__() self.registry = registry self.processor = processor - self._shown: Set[Tuple[str,str]] = set() # (dev_id, ch_id) + self._shown: Set[Tuple[str,str]] = set() + self._blocks: dict[Tuple[str,str], ChannelPipelineBlock] = {} self._build() def _build(self): @@ -285,10 +288,11 @@ class PipelineTab(QWidget): scroll.setWidget(self._cont) lay.addWidget(scroll, 1) - # Populate with all device channels on startup + # Populate with enabled device channels on startup for dev in self.registry.all_instances(): for ch in dev.info.channels: - self._add_block(dev.info.device_id, ch.channel_id, ch.name, ch.unit) + if ch.enabled: + self._add_block(dev.info.device_id, ch.channel_id, ch.name, ch.unit) self._refresh_avail_cb() @@ -301,9 +305,29 @@ class PipelineTab(QWidget): block = ChannelPipelineBlock(dev_id, ch_id, ch_name, unit, self.processor) block.changed.connect(self.changed) block.removed.connect(self._on_remove_block) + self._blocks[(dev_id, ch_id)] = block # Insert before the trailing stretch self._inner.insertWidget(self._inner.count() - 1, block) + def on_channel_enabled_changed(self, dev_id: str, ch_id: str, enabled: bool): + key = (dev_id, ch_id) + if enabled: + if key not in self._shown: + dev = self.registry.get_instance(dev_id) + if dev: + ch = dev.get_channel(ch_id) + if ch: + self._add_block(dev_id, ch_id, ch.name, ch.unit) + else: + block = self._blocks.get(key) + if block: + block.setVisible(True) + else: + block = self._blocks.get(key) + if block: + block.setVisible(False) + self._refresh_avail_cb() + def _on_add_channel(self): data = self._avail_cb.currentData() if not data: @@ -319,7 +343,9 @@ class PipelineTab(QWidget): self._refresh_avail_cb() def _on_remove_block(self, block: ChannelPipelineBlock): - self._shown.discard((block.dev_id, block.ch_id)) + key = (block.dev_id, block.ch_id) + self._shown.discard(key) + self._blocks.pop(key, None) self._inner.removeWidget(block) block.deleteLater() self._refresh_avail_cb() @@ -328,6 +354,8 @@ class PipelineTab(QWidget): self._avail_cb.clear() for dev in self.registry.all_instances(): for ch in dev.info.channels: + if not ch.enabled: + continue if (dev.info.device_id, ch.channel_id) not in self._shown: label = f"{dev.info.device_id} / {ch.channel_id} ({ch.name})" if ch.unit: @@ -520,7 +548,8 @@ class DerivedBlock(QFrame): # ══════════════════════════════════════════════════════════════════════════════ class SignalsTab(QWidget): - changed = pyqtSignal() + changed = pyqtSignal() + channel_toggled = pyqtSignal(str, str, bool) # dev_id, ch_id, enabled def __init__(self, registry: DeviceRegistry, processor: SignalProcessor): super().__init__() @@ -547,13 +576,19 @@ class SignalsTab(QWidget): # Physical channels — flat list across all devices for dev in self.registry.all_instances(): for ch in dev.info.channels: + dev_id_cap = dev.info.device_id + ch_id_cap = ch.channel_id self._lay.addWidget(self._make_row( color = ch.color, name = f"{ch.channel_id} — {ch.name}", unit = ch.unit, - source = dev.info.device_id, + source = dev_id_cap, enabled = ch.enabled, - on_toggle = lambda v, c=ch: (setattr(c,"enabled",v), self.changed.emit()), + on_toggle = lambda v, c=ch, d=dev_id_cap, ci=ch_id_cap: ( + setattr(c, "enabled", v), + self.changed.emit(), + self.channel_toggled.emit(d, ci, v), + ), )) # Thin divider before derived (only if both sections have entries) @@ -609,6 +644,7 @@ class SignalsWindow(QWidget): pipeline_changed = pyqtSignal() derived_changed = pyqtSignal() visibility_changed = pyqtSignal() + channel_toggled = pyqtSignal(str, str, bool) # dev_id, ch_id, enabled closed = pyqtSignal() def __init__(self, registry: DeviceRegistry, @@ -649,6 +685,7 @@ class SignalsWindow(QWidget): # Tab 3: Flat signals list self._vis_tab = SignalsTab(self.registry, self.processor) self._vis_tab.changed.connect(self.visibility_changed) + self._vis_tab.channel_toggled.connect(self._on_vis_tab_toggled) tabs.addTab(self._vis_tab, " Signals ") # Keep Signals tab in sync when derived channels are added/removed @@ -720,5 +757,15 @@ class SignalsWindow(QWidget): self._color_idx = len(self._derived_blocks) self._vis_tab.refresh() + def _on_vis_tab_toggled(self, dev_id: str, ch_id: str, enabled: bool): + """Signals tab toggled a channel — propagate to pipeline + chart without re-refreshing vis_tab.""" + self._pip_tab.on_channel_enabled_changed(dev_id, ch_id, enabled) + self.channel_toggled.emit(dev_id, ch_id, enabled) + + def on_channel_enabled_changed(self, dev_id: str, ch_id: str, enabled: bool): + """Called from outside (Devices window) — update pipeline block and sync vis_tab checkboxes.""" + self._pip_tab.on_channel_enabled_changed(dev_id, ch_id, enabled) + self._vis_tab.refresh() + def closeEvent(self, e: QCloseEvent): self.closed.emit(); e.accept() |
