summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--api_layers/__pycache__/__init__.cpython-312.pycbin320 -> 311 bytes
-rw-r--r--api_layers/__pycache__/__init__.cpython-314.pycbin322 -> 313 bytes
-rw-r--r--api_layers/__pycache__/arduino_layer.cpython-312.pycbin31077 -> 36567 bytes
-rw-r--r--api_layers/__pycache__/arduino_layer.cpython-314.pycbin35752 -> 41780 bytes
-rw-r--r--api_layers/__pycache__/nidaqmx_layer.cpython-312.pycbin8503 -> 8501 bytes
-rw-r--r--api_layers/__pycache__/nidaqmx_layer.cpython-314.pycbin10249 -> 10240 bytes
-rw-r--r--api_layers/__pycache__/port_registry.cpython-312.pycbin5983 -> 5907 bytes
-rw-r--r--api_layers/__pycache__/port_registry.cpython-314.pycbin0 -> 6791 bytes
-rw-r--r--api_layers/arduino_layer.py121
-rw-r--r--core/__pycache__/__init__.cpython-312.pycbin273 -> 264 bytes
-rw-r--r--core/__pycache__/acquisition.cpython-312.pycbin11489 -> 11456 bytes
-rw-r--r--core/__pycache__/acquisition.cpython-314.pycbin13170 -> 13586 bytes
-rw-r--r--core/__pycache__/profile.cpython-312.pycbin14202 -> 14494 bytes
-rw-r--r--core/__pycache__/profile.cpython-314.pycbin13214 -> 16023 bytes
-rw-r--r--core/__pycache__/signal_processor.cpython-312.pycbin24645 -> 24525 bytes
-rw-r--r--core/__pycache__/signal_processor.cpython-314.pycbin29226 -> 30137 bytes
-rw-r--r--core/profile.py19
-rw-r--r--devices/__pycache__/__init__.cpython-312.pycbin301 -> 292 bytes
-rw-r--r--devices/__pycache__/__init__.cpython-314.pycbin303 -> 294 bytes
-rw-r--r--devices/__pycache__/analog_input.cpython-312.pycbin24804 -> 24808 bytes
-rw-r--r--devices/__pycache__/analog_input.cpython-314.pycbin27067 -> 27058 bytes
-rw-r--r--devices/__pycache__/arduino_device.cpython-312.pycbin0 -> 25605 bytes
-rw-r--r--devices/__pycache__/arduino_device.cpython-314.pycbin0 -> 28664 bytes
-rw-r--r--devices/__pycache__/base_device.cpython-312.pycbin5615 -> 5604 bytes
-rw-r--r--devices/__pycache__/base_device.cpython-314.pycbin7583 -> 7574 bytes
-rw-r--r--devices/__pycache__/device_registry.cpython-312.pycbin4590 -> 4571 bytes
-rw-r--r--devices/__pycache__/device_registry.cpython-314.pycbin5925 -> 5916 bytes
-rw-r--r--devices/__pycache__/digital_io.cpython-312.pycbin22622 -> 20778 bytes
-rw-r--r--devices/__pycache__/digital_io.cpython-314.pycbin25967 -> 24119 bytes
-rw-r--r--devices/__pycache__/nidaqmx_device.cpython-312.pycbin0 -> 22337 bytes
-rw-r--r--devices/__pycache__/nidaqmx_device.cpython-314.pycbin0 -> 25000 bytes
-rw-r--r--devices/__pycache__/serial_device.cpython-312.pycbin49674 -> 49685 bytes
-rw-r--r--devices/__pycache__/serial_device.cpython-314.pycbin54966 -> 54957 bytes
-rw-r--r--devices/arduino_device.py423
-rw-r--r--devices/digital_io.py25
-rw-r--r--devices/nidaqmx_device.py382
-rw-r--r--ui/__pycache__/__init__.cpython-312.pycbin149 -> 140 bytes
-rw-r--r--ui/__pycache__/__init__.cpython-314.pycbin151 -> 142 bytes
-rw-r--r--ui/__pycache__/add_device_dialog.cpython-312.pycbin29935 -> 27249 bytes
-rw-r--r--ui/__pycache__/add_device_dialog.cpython-314.pycbin32389 -> 29249 bytes
-rw-r--r--ui/__pycache__/config_dialog.cpython-312.pycbin6931 -> 4644 bytes
-rw-r--r--ui/__pycache__/config_dialog.cpython-314.pycbin0 -> 4746 bytes
-rw-r--r--ui/__pycache__/control_editor.cpython-312.pycbin22602 -> 22558 bytes
-rw-r--r--ui/__pycache__/control_editor.cpython-314.pycbin22435 -> 24973 bytes
-rw-r--r--ui/__pycache__/control_panel.cpython-312.pycbin42708 -> 42676 bytes
-rw-r--r--ui/__pycache__/control_panel.cpython-314.pycbin44201 -> 47116 bytes
-rw-r--r--ui/__pycache__/main_window.cpython-312.pycbin28788 -> 29520 bytes
-rw-r--r--ui/__pycache__/main_window.cpython-314.pycbin27190 -> 31743 bytes
-rw-r--r--ui/__pycache__/profile_manager_ui.cpython-312.pycbin8371 -> 8357 bytes
-rw-r--r--ui/__pycache__/strip_chart.cpython-312.pycbin14901 -> 15321 bytes
-rw-r--r--ui/__pycache__/strip_chart.cpython-314.pycbin16625 -> 17230 bytes
-rw-r--r--ui/add_device_dialog.py263
-rw-r--r--ui/config_dialog.py46
-rw-r--r--ui/main_window.py24
-rw-r--r--ui/strip_chart.py5
-rw-r--r--ui/windows/__pycache__/__init__.cpython-312.pycbin157 -> 148 bytes
-rw-r--r--ui/windows/__pycache__/devices_window.cpython-312.pycbin22041 -> 24793 bytes
-rw-r--r--ui/windows/__pycache__/devices_window.cpython-314.pycbin23231 -> 26153 bytes
-rw-r--r--ui/windows/__pycache__/plot_window.cpython-312.pycbin44685 -> 44632 bytes
-rw-r--r--ui/windows/__pycache__/plot_window.cpython-314.pycbin46111 -> 46224 bytes
-rw-r--r--ui/windows/__pycache__/settings_window.cpython-312.pycbin15935 -> 15906 bytes
-rw-r--r--ui/windows/__pycache__/settings_window.cpython-314.pycbin16392 -> 13698 bytes
-rw-r--r--ui/windows/__pycache__/signals_window.cpython-312.pycbin54087 -> 56462 bytes
-rw-r--r--ui/windows/__pycache__/signals_window.cpython-314.pycbin53420 -> 59737 bytes
-rw-r--r--ui/windows/devices_window.py96
-rw-r--r--ui/windows/plot_window.py4
-rw-r--r--ui/windows/settings_window.py44
-rw-r--r--ui/windows/signals_window.py61
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
index 689f52e..a9a035b 100644
--- a/api_layers/__pycache__/__init__.cpython-312.pyc
+++ b/api_layers/__pycache__/__init__.cpython-312.pyc
Binary files differ
diff --git a/api_layers/__pycache__/__init__.cpython-314.pyc b/api_layers/__pycache__/__init__.cpython-314.pyc
index d30ccf5..01ab34b 100644
--- a/api_layers/__pycache__/__init__.cpython-314.pyc
+++ b/api_layers/__pycache__/__init__.cpython-314.pyc
Binary files differ
diff --git a/api_layers/__pycache__/arduino_layer.cpython-312.pyc b/api_layers/__pycache__/arduino_layer.cpython-312.pyc
index a0191ad..cb2dd3b 100644
--- a/api_layers/__pycache__/arduino_layer.cpython-312.pyc
+++ b/api_layers/__pycache__/arduino_layer.cpython-312.pyc
Binary files differ
diff --git a/api_layers/__pycache__/arduino_layer.cpython-314.pyc b/api_layers/__pycache__/arduino_layer.cpython-314.pyc
index 1cfec4e..769e4f0 100644
--- a/api_layers/__pycache__/arduino_layer.cpython-314.pyc
+++ b/api_layers/__pycache__/arduino_layer.cpython-314.pyc
Binary files differ
diff --git a/api_layers/__pycache__/nidaqmx_layer.cpython-312.pyc b/api_layers/__pycache__/nidaqmx_layer.cpython-312.pyc
index d310f54..8dda62e 100644
--- a/api_layers/__pycache__/nidaqmx_layer.cpython-312.pyc
+++ b/api_layers/__pycache__/nidaqmx_layer.cpython-312.pyc
Binary files differ
diff --git a/api_layers/__pycache__/nidaqmx_layer.cpython-314.pyc b/api_layers/__pycache__/nidaqmx_layer.cpython-314.pyc
index 6902c49..c11967d 100644
--- a/api_layers/__pycache__/nidaqmx_layer.cpython-314.pyc
+++ b/api_layers/__pycache__/nidaqmx_layer.cpython-314.pyc
Binary files differ
diff --git a/api_layers/__pycache__/port_registry.cpython-312.pyc b/api_layers/__pycache__/port_registry.cpython-312.pyc
index 48e7384..088d6d3 100644
--- a/api_layers/__pycache__/port_registry.cpython-312.pyc
+++ b/api_layers/__pycache__/port_registry.cpython-312.pyc
Binary files differ
diff --git a/api_layers/__pycache__/port_registry.cpython-314.pyc b/api_layers/__pycache__/port_registry.cpython-314.pyc
new file mode 100644
index 0000000..3ff0ab0
--- /dev/null
+++ b/api_layers/__pycache__/port_registry.cpython-314.pyc
Binary files differ
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
index 79fc26c..91f5de3 100644
--- a/core/__pycache__/__init__.cpython-312.pyc
+++ b/core/__pycache__/__init__.cpython-312.pyc
Binary files differ
diff --git a/core/__pycache__/acquisition.cpython-312.pyc b/core/__pycache__/acquisition.cpython-312.pyc
index d1b61fc..cb5f3f3 100644
--- a/core/__pycache__/acquisition.cpython-312.pyc
+++ b/core/__pycache__/acquisition.cpython-312.pyc
Binary files differ
diff --git a/core/__pycache__/acquisition.cpython-314.pyc b/core/__pycache__/acquisition.cpython-314.pyc
index 8a48b96..1b9ff56 100644
--- a/core/__pycache__/acquisition.cpython-314.pyc
+++ b/core/__pycache__/acquisition.cpython-314.pyc
Binary files differ
diff --git a/core/__pycache__/profile.cpython-312.pyc b/core/__pycache__/profile.cpython-312.pyc
index 9105704..162a593 100644
--- a/core/__pycache__/profile.cpython-312.pyc
+++ b/core/__pycache__/profile.cpython-312.pyc
Binary files differ
diff --git a/core/__pycache__/profile.cpython-314.pyc b/core/__pycache__/profile.cpython-314.pyc
index f009c3f..b790fac 100644
--- a/core/__pycache__/profile.cpython-314.pyc
+++ b/core/__pycache__/profile.cpython-314.pyc
Binary files differ
diff --git a/core/__pycache__/signal_processor.cpython-312.pyc b/core/__pycache__/signal_processor.cpython-312.pyc
index 505a573..4572e55 100644
--- a/core/__pycache__/signal_processor.cpython-312.pyc
+++ b/core/__pycache__/signal_processor.cpython-312.pyc
Binary files differ
diff --git a/core/__pycache__/signal_processor.cpython-314.pyc b/core/__pycache__/signal_processor.cpython-314.pyc
index 4a945a6..9076bcf 100644
--- a/core/__pycache__/signal_processor.cpython-314.pyc
+++ b/core/__pycache__/signal_processor.cpython-314.pyc
Binary files differ
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
index 88e8062..0e0929f 100644
--- a/devices/__pycache__/__init__.cpython-312.pyc
+++ b/devices/__pycache__/__init__.cpython-312.pyc
Binary files differ
diff --git a/devices/__pycache__/__init__.cpython-314.pyc b/devices/__pycache__/__init__.cpython-314.pyc
index fb1a2a7..e06c626 100644
--- a/devices/__pycache__/__init__.cpython-314.pyc
+++ b/devices/__pycache__/__init__.cpython-314.pyc
Binary files differ
diff --git a/devices/__pycache__/analog_input.cpython-312.pyc b/devices/__pycache__/analog_input.cpython-312.pyc
index 931b605..f22ffd6 100644
--- a/devices/__pycache__/analog_input.cpython-312.pyc
+++ b/devices/__pycache__/analog_input.cpython-312.pyc
Binary files differ
diff --git a/devices/__pycache__/analog_input.cpython-314.pyc b/devices/__pycache__/analog_input.cpython-314.pyc
index d82bc07..fe88c04 100644
--- a/devices/__pycache__/analog_input.cpython-314.pyc
+++ b/devices/__pycache__/analog_input.cpython-314.pyc
Binary files differ
diff --git a/devices/__pycache__/arduino_device.cpython-312.pyc b/devices/__pycache__/arduino_device.cpython-312.pyc
new file mode 100644
index 0000000..7c8c5d8
--- /dev/null
+++ b/devices/__pycache__/arduino_device.cpython-312.pyc
Binary files differ
diff --git a/devices/__pycache__/arduino_device.cpython-314.pyc b/devices/__pycache__/arduino_device.cpython-314.pyc
new file mode 100644
index 0000000..531488c
--- /dev/null
+++ b/devices/__pycache__/arduino_device.cpython-314.pyc
Binary files differ
diff --git a/devices/__pycache__/base_device.cpython-312.pyc b/devices/__pycache__/base_device.cpython-312.pyc
index d5604d7..087e500 100644
--- a/devices/__pycache__/base_device.cpython-312.pyc
+++ b/devices/__pycache__/base_device.cpython-312.pyc
Binary files differ
diff --git a/devices/__pycache__/base_device.cpython-314.pyc b/devices/__pycache__/base_device.cpython-314.pyc
index fa99267..b8f94f4 100644
--- a/devices/__pycache__/base_device.cpython-314.pyc
+++ b/devices/__pycache__/base_device.cpython-314.pyc
Binary files differ
diff --git a/devices/__pycache__/device_registry.cpython-312.pyc b/devices/__pycache__/device_registry.cpython-312.pyc
index 276f1e2..3495e35 100644
--- a/devices/__pycache__/device_registry.cpython-312.pyc
+++ b/devices/__pycache__/device_registry.cpython-312.pyc
Binary files differ
diff --git a/devices/__pycache__/device_registry.cpython-314.pyc b/devices/__pycache__/device_registry.cpython-314.pyc
index 6912654..f3f7d3e 100644
--- a/devices/__pycache__/device_registry.cpython-314.pyc
+++ b/devices/__pycache__/device_registry.cpython-314.pyc
Binary files differ
diff --git a/devices/__pycache__/digital_io.cpython-312.pyc b/devices/__pycache__/digital_io.cpython-312.pyc
index c0262d5..546f9db 100644
--- a/devices/__pycache__/digital_io.cpython-312.pyc
+++ b/devices/__pycache__/digital_io.cpython-312.pyc
Binary files differ
diff --git a/devices/__pycache__/digital_io.cpython-314.pyc b/devices/__pycache__/digital_io.cpython-314.pyc
index d51aaf5..e0fc182 100644
--- a/devices/__pycache__/digital_io.cpython-314.pyc
+++ b/devices/__pycache__/digital_io.cpython-314.pyc
Binary files differ
diff --git a/devices/__pycache__/nidaqmx_device.cpython-312.pyc b/devices/__pycache__/nidaqmx_device.cpython-312.pyc
new file mode 100644
index 0000000..04f8f94
--- /dev/null
+++ b/devices/__pycache__/nidaqmx_device.cpython-312.pyc
Binary files differ
diff --git a/devices/__pycache__/nidaqmx_device.cpython-314.pyc b/devices/__pycache__/nidaqmx_device.cpython-314.pyc
new file mode 100644
index 0000000..27afd3f
--- /dev/null
+++ b/devices/__pycache__/nidaqmx_device.cpython-314.pyc
Binary files differ
diff --git a/devices/__pycache__/serial_device.cpython-312.pyc b/devices/__pycache__/serial_device.cpython-312.pyc
index e885b40..b61267b 100644
--- a/devices/__pycache__/serial_device.cpython-312.pyc
+++ b/devices/__pycache__/serial_device.cpython-312.pyc
Binary files differ
diff --git a/devices/__pycache__/serial_device.cpython-314.pyc b/devices/__pycache__/serial_device.cpython-314.pyc
index 976e575..3263217 100644
--- a/devices/__pycache__/serial_device.cpython-314.pyc
+++ b/devices/__pycache__/serial_device.cpython-314.pyc
Binary files differ
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
index 42ac581..8dc1c2f 100644
--- a/ui/__pycache__/__init__.cpython-312.pyc
+++ b/ui/__pycache__/__init__.cpython-312.pyc
Binary files differ
diff --git a/ui/__pycache__/__init__.cpython-314.pyc b/ui/__pycache__/__init__.cpython-314.pyc
index ef40b13..15d71d0 100644
--- a/ui/__pycache__/__init__.cpython-314.pyc
+++ b/ui/__pycache__/__init__.cpython-314.pyc
Binary files differ
diff --git a/ui/__pycache__/add_device_dialog.cpython-312.pyc b/ui/__pycache__/add_device_dialog.cpython-312.pyc
index 990c229..1e970d6 100644
--- a/ui/__pycache__/add_device_dialog.cpython-312.pyc
+++ b/ui/__pycache__/add_device_dialog.cpython-312.pyc
Binary files differ
diff --git a/ui/__pycache__/add_device_dialog.cpython-314.pyc b/ui/__pycache__/add_device_dialog.cpython-314.pyc
index f5852ba..687bc1e 100644
--- a/ui/__pycache__/add_device_dialog.cpython-314.pyc
+++ b/ui/__pycache__/add_device_dialog.cpython-314.pyc
Binary files differ
diff --git a/ui/__pycache__/config_dialog.cpython-312.pyc b/ui/__pycache__/config_dialog.cpython-312.pyc
index 5a051dc..91921e5 100644
--- a/ui/__pycache__/config_dialog.cpython-312.pyc
+++ b/ui/__pycache__/config_dialog.cpython-312.pyc
Binary files differ
diff --git a/ui/__pycache__/config_dialog.cpython-314.pyc b/ui/__pycache__/config_dialog.cpython-314.pyc
new file mode 100644
index 0000000..91d6b9b
--- /dev/null
+++ b/ui/__pycache__/config_dialog.cpython-314.pyc
Binary files differ
diff --git a/ui/__pycache__/control_editor.cpython-312.pyc b/ui/__pycache__/control_editor.cpython-312.pyc
index e76411d..3a53377 100644
--- a/ui/__pycache__/control_editor.cpython-312.pyc
+++ b/ui/__pycache__/control_editor.cpython-312.pyc
Binary files differ
diff --git a/ui/__pycache__/control_editor.cpython-314.pyc b/ui/__pycache__/control_editor.cpython-314.pyc
index aaea0ee..d3904f7 100644
--- a/ui/__pycache__/control_editor.cpython-314.pyc
+++ b/ui/__pycache__/control_editor.cpython-314.pyc
Binary files differ
diff --git a/ui/__pycache__/control_panel.cpython-312.pyc b/ui/__pycache__/control_panel.cpython-312.pyc
index 45f1eb9..1c101a8 100644
--- a/ui/__pycache__/control_panel.cpython-312.pyc
+++ b/ui/__pycache__/control_panel.cpython-312.pyc
Binary files differ
diff --git a/ui/__pycache__/control_panel.cpython-314.pyc b/ui/__pycache__/control_panel.cpython-314.pyc
index 79f9084..a5b859f 100644
--- a/ui/__pycache__/control_panel.cpython-314.pyc
+++ b/ui/__pycache__/control_panel.cpython-314.pyc
Binary files differ
diff --git a/ui/__pycache__/main_window.cpython-312.pyc b/ui/__pycache__/main_window.cpython-312.pyc
index 3aefd67..ffd6e12 100644
--- a/ui/__pycache__/main_window.cpython-312.pyc
+++ b/ui/__pycache__/main_window.cpython-312.pyc
Binary files differ
diff --git a/ui/__pycache__/main_window.cpython-314.pyc b/ui/__pycache__/main_window.cpython-314.pyc
index acef085..709972c 100644
--- a/ui/__pycache__/main_window.cpython-314.pyc
+++ b/ui/__pycache__/main_window.cpython-314.pyc
Binary files differ
diff --git a/ui/__pycache__/profile_manager_ui.cpython-312.pyc b/ui/__pycache__/profile_manager_ui.cpython-312.pyc
index 93491f7..97cbe3a 100644
--- a/ui/__pycache__/profile_manager_ui.cpython-312.pyc
+++ b/ui/__pycache__/profile_manager_ui.cpython-312.pyc
Binary files differ
diff --git a/ui/__pycache__/strip_chart.cpython-312.pyc b/ui/__pycache__/strip_chart.cpython-312.pyc
index a3e25c0..33e713b 100644
--- a/ui/__pycache__/strip_chart.cpython-312.pyc
+++ b/ui/__pycache__/strip_chart.cpython-312.pyc
Binary files differ
diff --git a/ui/__pycache__/strip_chart.cpython-314.pyc b/ui/__pycache__/strip_chart.cpython-314.pyc
index e56015f..ef745d3 100644
--- a/ui/__pycache__/strip_chart.cpython-314.pyc
+++ b/ui/__pycache__/strip_chart.cpython-314.pyc
Binary files differ
diff --git a/ui/add_device_dialog.py b/ui/add_device_dialog.py
index 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
index 1454fd8..66495c5 100644
--- a/ui/windows/__pycache__/__init__.cpython-312.pyc
+++ b/ui/windows/__pycache__/__init__.cpython-312.pyc
Binary files differ
diff --git a/ui/windows/__pycache__/devices_window.cpython-312.pyc b/ui/windows/__pycache__/devices_window.cpython-312.pyc
index fb52b79..dc62b48 100644
--- a/ui/windows/__pycache__/devices_window.cpython-312.pyc
+++ b/ui/windows/__pycache__/devices_window.cpython-312.pyc
Binary files differ
diff --git a/ui/windows/__pycache__/devices_window.cpython-314.pyc b/ui/windows/__pycache__/devices_window.cpython-314.pyc
index e1a4868..9f819dd 100644
--- a/ui/windows/__pycache__/devices_window.cpython-314.pyc
+++ b/ui/windows/__pycache__/devices_window.cpython-314.pyc
Binary files differ
diff --git a/ui/windows/__pycache__/plot_window.cpython-312.pyc b/ui/windows/__pycache__/plot_window.cpython-312.pyc
index 7499763..d3a1583 100644
--- a/ui/windows/__pycache__/plot_window.cpython-312.pyc
+++ b/ui/windows/__pycache__/plot_window.cpython-312.pyc
Binary files differ
diff --git a/ui/windows/__pycache__/plot_window.cpython-314.pyc b/ui/windows/__pycache__/plot_window.cpython-314.pyc
index 30b0a23..9782293 100644
--- a/ui/windows/__pycache__/plot_window.cpython-314.pyc
+++ b/ui/windows/__pycache__/plot_window.cpython-314.pyc
Binary files differ
diff --git a/ui/windows/__pycache__/settings_window.cpython-312.pyc b/ui/windows/__pycache__/settings_window.cpython-312.pyc
index 47d5611..d596487 100644
--- a/ui/windows/__pycache__/settings_window.cpython-312.pyc
+++ b/ui/windows/__pycache__/settings_window.cpython-312.pyc
Binary files differ
diff --git a/ui/windows/__pycache__/settings_window.cpython-314.pyc b/ui/windows/__pycache__/settings_window.cpython-314.pyc
index cc9691b..f732521 100644
--- a/ui/windows/__pycache__/settings_window.cpython-314.pyc
+++ b/ui/windows/__pycache__/settings_window.cpython-314.pyc
Binary files differ
diff --git a/ui/windows/__pycache__/signals_window.cpython-312.pyc b/ui/windows/__pycache__/signals_window.cpython-312.pyc
index ec57533..9872798 100644
--- a/ui/windows/__pycache__/signals_window.cpython-312.pyc
+++ b/ui/windows/__pycache__/signals_window.cpython-312.pyc
Binary files differ
diff --git a/ui/windows/__pycache__/signals_window.cpython-314.pyc b/ui/windows/__pycache__/signals_window.cpython-314.pyc
index 224e31e..2027f59 100644
--- a/ui/windows/__pycache__/signals_window.cpython-314.pyc
+++ b/ui/windows/__pycache__/signals_window.cpython-314.pyc
Binary files differ
diff --git a/ui/windows/devices_window.py b/ui/windows/devices_window.py
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()