""" ui/add_device_dialog.py Add Device dialog — unified device scanner + type/config form. """ from PyQt6.QtWidgets import ( QDialog, QVBoxLayout, QHBoxLayout, QFormLayout, QComboBox, QLineEdit, QSpinBox, QDoubleSpinBox, QCheckBox, QPushButton, QLabel, QMessageBox, QStackedWidget, QWidget, QListWidget, QListWidgetItem, QFrame, QSizePolicy, ) from PyQt6.QtCore import Qt, QThread, pyqtSignal from devices.device_registry import DeviceRegistry from devices.arduino_device import ArduinoDevice from devices.nidaqmx_device import NidaqmxDevice from devices.serial_device import SerialDevice, _FORMAT_LABELS from core.app_settings import is_developer_mode # ── 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()) class NIScanThread(QThread): devices_found = 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.devices_found.emit(devs) # ── Device list scanner (embedded in Available Devices section) ─────────────── class _DeviceListScanner(QWidget): """Scan button + result list. Emits device_selected(kind, value) on click.""" device_selected = pyqtSignal(str, object) # kind = "serial"|"ni"|"camera" def __init__(self): super().__init__() self._port_scanner = None self._ni_scanner = None self._cam_scanner = None self._pending = 0 lay = QVBoxLayout(self) lay.setContentsMargins(0, 0, 0, 0) lay.setSpacing(4) top = QHBoxLayout() self._scan_btn = QPushButton("🔍 Refresh") self._scan_btn.setObjectName("addTraceBtn") self._scan_btn.clicked.connect(self.scan_all) self._status = QLabel("Scanning…") self._status.setObjectName("traceSource") top.addWidget(self._scan_btn) top.addWidget(self._status, 1) lay.addLayout(top) self._list = QListWidget() self._list.setFixedHeight(120) self._list.setObjectName("portList") self._list.itemClicked.connect(self._on_select) self._list.setToolTip("Click a device to pre-fill configuration") lay.addWidget(self._list) hint = QLabel("↑ Click a device to pre-fill port and configuration") hint.setObjectName("traceSource") lay.addWidget(hint) def scan_all(self): self._scan_btn.setEnabled(False) self._status.setText("Scanning…") self._list.clear() self._pending = 0 self._pending += 1 self._port_scanner = PortScanThread() self._port_scanner.ports_found.connect(self._on_ports) self._port_scanner.start() self._pending += 1 self._ni_scanner = NIScanThread() self._ni_scanner.devices_found.connect(self._on_ni) self._ni_scanner.start() try: from plugins.motion_capture.camera_panel import CameraScanThread self._pending += 1 self._cam_scanner = CameraScanThread() self._cam_scanner.cameras_found.connect(self._on_cameras) self._cam_scanner.start() except Exception: pass def _check_done(self): self._pending -= 1 if self._pending <= 0: self._scan_btn.setEnabled(True) n = sum( 1 for i in range(self._list.count()) if self._list.item(i).flags() & Qt.ItemFlag.ItemIsSelectable ) self._status.setText(f"{n} device(s) found" if n else "No devices found") def _on_ports(self, ports): for device, desc in ports: label = f"[Serial] {device}" if desc and desc != device: label += f" — {desc}" item = QListWidgetItem(label) item.setData(Qt.ItemDataRole.UserRole, ("serial", device)) self._list.addItem(item) self._check_done() def _on_ni(self, devices): for name, product in devices: label = f"[NI-DAQ] {name}" if product: label += f" — {product}" item = QListWidgetItem(label) item.setData(Qt.ItemDataRole.UserRole, ("ni", name)) self._list.addItem(item) self._check_done() def _on_cameras(self, cameras): for idx, label_str in cameras: item = QListWidgetItem(f"[Camera] {label_str}") item.setData(Qt.ItemDataRole.UserRole, ("camera", idx)) self._list.addItem(item) self._check_done() def _on_select(self, item: QListWidgetItem): data = item.data(Qt.ItemDataRole.UserRole) if data: self.device_selected.emit(data[0], data[1]) # ── Per-type panels (remaining type-specific fields only) ───────────────────── class SerialPanel(QWidget): def __init__(self): super().__init__() lay = QVBoxLayout(self) lay.setContentsMargins(0, 4, 0, 4) note = QLabel("Additional protocol settings available in the device config dialog.") note.setObjectName("traceSource") note.setWordWrap(True) lay.addWidget(note) lay.addStretch() class ArduinoPanel(QWidget): def __init__(self): super().__init__() lay = QFormLayout(self) lay.setContentsMargins(0, 4, 0, 4) 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.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.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) class NidaqmxPanel(QWidget): def __init__(self): super().__init__() lay = QFormLayout(self) lay.setContentsMargins(0, 4, 0, 4) self.analog_spin = QSpinBox() self.analog_spin.setRange(0, 16) self.analog_spin.setValue(4) lay.addRow("Analog Inputs:", self.analog_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.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.di_spin = QSpinBox() self.di_spin.setRange(0, 32) self.di_spin.setValue(2) lay.addRow("Digital Inputs:", self.di_spin) self.do_spin = QSpinBox() self.do_spin.setRange(0, 32) self.do_spin.setValue(4) lay.addRow("Digital Outputs:", self.do_spin) # ── Panel registry (order matters: first = default) ─────────────────────────── _PANELS = { "Serial / UART": (SerialPanel, "ser"), "Arduino": (ArduinoPanel, "ard"), "NI-DAQ": (NidaqmxPanel, "ni"), } _SERIAL_TYPES = {"Serial / UART", "Arduino"} _NI_TYPES = {"NI-DAQ"} # ── Main dialog ─────────────────────────────────────────────────────────────── class AddDeviceDialog(QDialog): def __init__(self, registry: DeviceRegistry, parent=None): super().__init__(parent) self.registry = registry self.created_device = None self.setWindowTitle("Add Device") self.setMinimumSize(480, 580) self.resize(500, 700) self._build() def _build(self): root = QVBoxLayout(self) root.setSpacing(10) hdr = QLabel("Add New Device") hdr.setObjectName("devWindowTitle") root.addWidget(hdr) root.addWidget(_divider()) # ── Available Devices ───────────────────────────────────────────── avail_hdr = QLabel("Available Devices") avail_hdr.setObjectName("devWindowTitle") root.addWidget(avail_hdr) self._scanner = _DeviceListScanner() self._scanner.device_selected.connect(self._on_device_selected) root.addWidget(self._scanner) conn_form = QFormLayout() conn_form.setSpacing(6) self._port_edit = QLineEdit() self._port_edit.setPlaceholderText("e.g. COM3 or /dev/ttyUSB0") self._port_lbl = QLabel("Port:") conn_form.addRow(self._port_lbl, self._port_edit) self._baud_cb = QComboBox() self._baud_cb.addItems(["1200", "2400", "4800", "9600", "19200", "38400", "57600", "115200", "230400", "460800"]) self._baud_cb.setCurrentText("115200") self._baud_lbl = QLabel("Baud Rate:") conn_form.addRow(self._baud_lbl, self._baud_cb) self._ni_edit = QLineEdit("Dev1") self._ni_lbl = QLabel("NI Device:") conn_form.addRow(self._ni_lbl, self._ni_edit) self._sim_chk = QCheckBox("Simulation mode") self._sim_chk.setVisible(is_developer_mode()) # dev-mode-only escape hatch conn_form.addRow(self._sim_chk) root.addLayout(conn_form) root.addWidget(_divider()) # ── Configuration ───────────────────────────────────────────────── cfg_hdr = QLabel("Configuration") cfg_hdr.setObjectName("devWindowTitle") root.addWidget(cfg_hdr) cfg_form = QFormLayout() cfg_form.setSpacing(6) self._type_cb = QComboBox() self._type_cb.addItems(list(_PANELS.keys())) self._type_cb.currentIndexChanged.connect(self._on_type_changed) cfg_form.addRow("Device Type:", self._type_cb) self._id_edit = QLineEdit() self._id_edit.setPlaceholderText("Leave blank for auto") cfg_form.addRow("Device ID:", self._id_edit) self._fmt_cb = QComboBox() self._fmt_cb.addItems(list(_FORMAT_LABELS.keys())) self._fmt_lbl = QLabel("Protocol / Format:") cfg_form.addRow(self._fmt_lbl, self._fmt_cb) root.addLayout(cfg_form) self._stack = QStackedWidget() self._panels = {} for name, (cls, _prefix) in _PANELS.items(): panel = cls() self._panels[name] = panel self._stack.addWidget(panel) root.addWidget(self._stack, 1) root.addWidget(_divider()) btn_row = QHBoxLayout() btn_row.addStretch() cancel = QPushButton("Cancel") cancel.clicked.connect(self.reject) self._add_btn = QPushButton("Add Device") self._add_btn.setObjectName("applyButton") self._add_btn.setDefault(True) self._add_btn.clicked.connect(self._on_add) btn_row.addWidget(cancel) btn_row.addWidget(self._add_btn) root.addLayout(btn_row) self._on_type_changed(0) from PyQt6.QtCore import QTimer QTimer.singleShot(0, self._scanner.scan_all) # ── Handlers ────────────────────────────────────────────────────────── def _on_device_selected(self, kind: str, value): if kind == "serial": self._port_edit.setText(value) self._set_type("Serial / UART") elif kind == "ni": self._ni_edit.setText(value) self._set_type("NI-DAQ") elif kind == "camera": self._set_type("Camera") panel = self._panels.get("Camera") if panel and hasattr(panel, "set_camera_index"): panel.set_camera_index(value) def _set_type(self, name: str): idx = self._type_cb.findText(name) if idx >= 0: self._type_cb.setCurrentIndex(idx) def _on_type_changed(self, idx: int): self._stack.setCurrentIndex(idx) type_name = self._type_cb.currentText() is_serial = type_name in _SERIAL_TYPES is_ni = type_name in _NI_TYPES is_serial_uart = type_name == "Serial / UART" self._port_lbl.setVisible(is_serial) self._port_edit.setVisible(is_serial) self._baud_lbl.setVisible(is_serial) self._baud_cb.setVisible(is_serial) self._ni_lbl.setVisible(is_ni) self._ni_edit.setVisible(is_ni) self._fmt_lbl.setVisible(is_serial_uart) self._fmt_cb.setVisible(is_serial_uart) _, 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}" if candidate not in existing: self._id_edit.setPlaceholderText(f"e.g. {candidate} (auto)") break def _on_add(self): type_name = self._type_cb.currentText() panel = self._panels[type_name] sim = self._sim_chk.isChecked() if type_name in _SERIAL_TYPES and not sim and not self._port_edit.text().strip(): QMessageBox.warning(self, "Port Required", "Enter a port (e.g. COM3 or /dev/ttyUSB0)\n" "or enable Simulation mode.") self._port_edit.setFocus() return if type_name in _NI_TYPES and not sim and not self._ni_edit.text().strip(): QMessageBox.warning(self, "NI Device Required", "Enter an NI device name (e.g. Dev1)\n" "or enable Simulation mode.") self._ni_edit.setFocus() return dev_id = self._id_edit.text().strip() if not dev_id: _, 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}" if candidate not in existing: dev_id = candidate break if self.registry.get_instance(dev_id): QMessageBox.warning(self, "Duplicate ID", f"A device with ID '{dev_id}' already exists.\n" f"Choose a different ID.") return port = self._port_edit.text().strip() or "COM3" baud = int(self._baud_cb.currentText()) ni = self._ni_edit.text().strip() or "Dev1" fmt = _FORMAT_LABELS.get(self._fmt_cb.currentText(), "key:val") try: if type_name == "Serial / UART": dev = SerialDevice( device_id=dev_id, port=port, baud_rate=baud, parse_format=fmt, simulate=sim, ) elif type_name == "Arduino": from devices.arduino_device import _parse_pin_edit, _parse_analog_pin_edit analog = _parse_analog_pin_edit(panel.analog_pins_edit.text()) or None di = _parse_pin_edit(panel.di_pins_edit.text()) or None do = _parse_pin_edit(panel.do_pins_edit.text()) or None dev = ArduinoDevice( device_id=dev_id, analog_pins=analog, di_pins=di, do_pins=do, simulate=sim, port=port, baud=baud, ) elif type_name == "NI-DAQ": dev = NidaqmxDevice( device_id=dev_id, num_analog=panel.analog_spin.value(), min_v=panel.min_v_spin.value(), max_v=panel.max_v_spin.value(), num_di=panel.di_spin.value(), num_do=panel.do_spin.value(), simulate=sim, ni_device=ni, ) else: if hasattr(panel, "set_simulate"): panel.set_simulate(sim) dev = panel.build_device(dev_id) self.created_device = dev self.accept() except Exception as e: QMessageBox.critical(self, "Error creating device", str(e)) # ── Helpers ─────────────────────────────────────────────────────────────────── def _divider() -> QFrame: div = QFrame() div.setFrameShape(QFrame.Shape.HLine) div.setObjectName("devWindowDivider") return div