summaryrefslogtreecommitdiff
path: root/devices
diff options
context:
space:
mode:
authorChristian Kolset <christian.kolset@gmail.com>2026-04-27 16:53:14 -0600
committerChristian Kolset <christian.kolset@gmail.com>2026-04-27 16:53:14 -0600
commit2b9943ba0d28449a590acfa8a41555b174b6ba84 (patch)
treee8bef8c9bc61dd7e2f9c8a9b177058a4b09546db /devices
parent67cfa0a514c7de4605ed7360e15a81aa781e510e (diff)
Improved arduino interface. Now allowing the user to configure pins on the fly.
Diffstat (limited to 'devices')
-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
19 files changed, 805 insertions, 25 deletions
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()